Skip to main content
Meridian uses custom ed25519-signed tokens — smaller than JWT, constant-time verification.

Token format

base64url(msgpack(claims)) + "." + base64url(ed25519_signature)
Claims:
FieldTypeDescription
namespacestringThe namespace this token grants access to
client_idnumberUnique numeric ID for this client
expires_atnumberExpiry timestamp in milliseconds
permissionsobjectV1 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:
{ "token": "eyJ..." }

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-*"]
    }
  }'
PatternMatches
"*"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

FieldTypeDescription
pstringGlob pattern matched against crdt_id
onumberOp mask bitmask — which operations are allowed. Absent = all ops
enumberPer-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:
CRDTOpMask
GCounterincrement0x01
PNCounterincrement0x01
PNCounterdecrement0x02
ORSetadd0x01
ORSetremove0x02
LwwRegisterset0x01
Presenceupdate0x01
RGAinsert0x01
RGAdelete0x02
Treeadd node0x01
Treemove node0x02
Treeupdate node0x04
Treedelete node0x08
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")),
  )
);