Meridian uses custom ed25519-signed tokens — smaller than JWT, constant-time verification.
base64url(msgpack(claims)) + "." + base64url(ed25519_signature)
Claims:
| Field | Type | Description |
|---|
namespace | string | The namespace this token grants access to |
client_id | number | Unique numeric ID for this client |
expires_at | number | Expiry timestamp in milliseconds |
permissions | object | V1 glob lists or V2 fine-grained rules (see below) |
Issue a token via HTTP
curl -X POST http://localhost:3000/v1/namespaces/my-room/tokens \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "client_id": 42, "ttl_ms": 3600000, "permissions": { "read": ["*"], "write": ["*"] } }'
Response:
Permissions — V1 (glob lists)
V1 permissions use glob patterns matched against the CRDT key (crdt_id). Issue a V1 token with the permissions field:
curl -X POST http://localhost:3000/v1/namespaces/shop/tokens \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"client_id": 42,
"permissions": {
"read": ["*"],
"write": ["or:cart-42", "pr:room-*"]
}
}'
| Pattern | Matches |
|---|
"*" | All keys (full access) |
"gc:*" | All GCounter keys |
"or:cart-42" | Only or:cart-42 |
"or:cart-*" | All cart keys for any user |
"lw:title" | Only the lw:title key |
Permissions — V2 (fine-grained rules)
V2 tokens add per-rule op masks, per-rule TTLs, and a rate limit override. Issue a V2 token with the rules field:
curl -X POST http://localhost:3000/v1/namespaces/shop/tokens \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"client_id": 42,
"rules": {
"r": [{ "p": "*" }],
"w": [
{ "p": "gc:views", "o": 1 },
{ "p": "or:cart-42" }
],
"rl": 200
}
}'
Rule fields
| Field | Type | Description |
|---|
p | string | Glob pattern matched against crdt_id |
o | number | Op mask bitmask — which operations are allowed. Absent = all ops |
e | number | Per-rule expiry timestamp in ms. Absent = no extra expiry |
Rules are evaluated first-match-wins in order. The token-level expires_at still applies.
Op mask constants
Each CRDT type uses its own bit positions:
| CRDT | Op | Mask |
|---|
| GCounter | increment | 0x01 |
| PNCounter | increment | 0x01 |
| PNCounter | decrement | 0x02 |
| ORSet | add | 0x01 |
| ORSet | remove | 0x02 |
| LwwRegister | set | 0x01 |
| Presence | update | 0x01 |
| RGA | insert | 0x01 |
| RGA | delete | 0x02 |
| Tree | add node | 0x01 |
| Tree | move node | 0x02 |
| Tree | update node | 0x04 |
| Tree | delete node | 0x08 |
Combine masks with bitwise OR: 0x01 | 0x02 = 0x03 allows both operations.
Example: read-only observer + increment-only counter
# Can read everything, can only increment gc:views (no decrement), rate-limited to 50 req/s
curl -X POST http://localhost:3000/v1/namespaces/analytics/tokens \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"client_id": 99,
"rules": {
"r": [{ "p": "*" }],
"w": [{ "p": "gc:views", "o": 1 }],
"rl": 50
}
}'
Example: time-limited write rule
# Write access to or:promo expires at a specific time, read access is permanent
curl -X POST http://localhost:3000/v1/namespaces/shop/tokens \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"client_id": 7,
"ttl_ms": 86400000,
"rules": {
"r": [{ "p": "*" }],
"w": [{ "p": "or:promo", "e": 1740000000000 }]
}
}'
{clientId} template — per-agent key scoping
Pattern values in V2 rules may contain the literal placeholder {clientId}. The server expands it to the token’s client_id before matching. This lets you issue a single token template that automatically scopes each agent to its own keys:
# Token issued to agent 42 — write access is restricted to "or:cart-42"
curl -X POST http://localhost:3000/v1/namespaces/shop/tokens \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"client_id": 42,
"rules": {
"r": [{ "p": "*" }],
"w": [{ "p": "or:cart-{clientId}" }]
}
}'
Agent 42 can write or:cart-42 but not or:cart-99. Agent 99, issued with the same rule template, can only write or:cart-99.
This is especially useful for multi-agent systems where each agent must be isolated to its own slice of the namespace:
{
"client_id": 7,
"rules": {
"r": [{ "p": "pr:agents-{clientId}" }],
"w": [{ "p": "pr:agents-{clientId}" }, { "p": "gc:work-{clientId}" }]
}
}
Rate limit override
The rl field (V2 only) sets a per-token rate limit in requests per second. This overrides the server default (100 req/s) for this specific token — useful to give trusted services higher throughput or restrict untrusted clients.
{ "rules": { "r": [{ "p": "*" }], "w": [{ "p": "*" }], "rl": 500 } }
Backward compatibility
V1 and V2 tokens coexist without migration. Old V1 tokens continue to work unchanged. The server detects the version automatically from the token payload.
A token with write: ["*"] (V1) or w: [{ "p": "*" }] (V2) can write to any key in the namespace. In multi-user namespaces, always scope write permissions to the keys the client actually owns.
Inspect a token
GET /v1/namespaces/:ns/tokens/me decodes the caller’s token and returns its claims as JSON. Useful for debugging — no msgpack tooling needed.
curl http://localhost:3000/v1/namespaces/shop/tokens/me \
-H "Authorization: Bearer $TOKEN"
{
"namespace": "shop",
"client_id": 42,
"expires_at": 1743000000000,
"permissions": {
"v": 2,
"r": [{ "p": "*" }],
"w": [{ "p": "or:cart-{clientId}" }]
}
}
V1 tokens return permissions with read, write, and admin fields instead.
Verify a token (SDK)
MeridianClient.create() automatically parses and validates the token before connecting:
import { Effect } from "effect";
import { MeridianClient, TokenExpiredError, TokenParseError } from "meridian-sdk";
await Effect.runPromise(
MeridianClient.create(config).pipe(
Effect.catchTag("TokenExpiredError", () => Effect.die("renew your token")),
Effect.catchTag("TokenParseError", () => Effect.die("invalid token")),
)
);