Status: fixed. Every UX lane builds against this file. All changes are additive. A client that knows
nothing of this file keeps working: the six-digit code path (/v1/pair, internal/pairbox,
internal/pair), {"v":N} watch frames, POST /v1/dir/devices/{id}/revoke and the existing
directory.Device JSON all stay as they are.
Words in UI strings follow the vocabulary law: "device", "Move here", "Continue here". The words in this file (relay, node, lease, manifest) never reach a screen.
Conventions: JSON bodies; errors are {"err":"<name>"} with the status given (the relay's existing
shape, wire.js). Times are Unix milliseconds. b64u is unpadded base64url. Every answer carries
Codeaf-Now as today. Signed routes use the existing identity/device cert headers and are called
"signed"; routes marked "open" take no identity (the new device has none yet).
A new device asks to join by creating a request. The relay keeps it for 10 minutes in one new Durable
Object, LinkGate (global decisions and per-IP counters, like PairGate), plus one LinkRequest
object per code (like Mailbox).
{
"code": "k7m2q9xd",
"device": "dev_3Fq9...",
"pubkey": "b64u(32 bytes ed25519 device public key)",
"x25519": "b64u(32 bytes, key the grant is sealed to)",
"name_sealed": "b64u(secretbox of the UTF-8 device name under the link key)",
"platform": "linux",
"check": "4821",
"requested_at": 1790000000000,
"expires_at": 1790000600000,
"state": "pending",
"grant": null
}code: 8 characters of Crockford base32 (no I, L, O, U; lowercase accepted, upper-cased on read), 40 random bits fromcrypto.getRandomValues. It is a lookup key, not a secret that protects keys.device:dev_id derived frompubkeyexactly as today; the relay recomputes it and refuses a mismatch (bad_request).platform: one ofdarwin,linux,windows,ios,android,other. Unknown values are stored asother. Plain text; it is the platform icon on the approve screen.name_sealed: the name is hidden from the relay. It is sealed (NaCl secretbox, 24-byte nonce prepended) under the 16-byte link keyk, which lives only in the URL fragment (section 2) and is never sent to any server. At most 96 bytes of name before sealing.check: four decimal digits,uint16_be(sha256(pubkey)[0:2]) mod 10000, zero padded. The new device shows it; the approver compares it. It makes a substituted public key visible.expires_at = requested_at + 600000(10 min,limits.pairTtlMs). After expiry every read is404 gone.state:pending|approved|denied. Moves once, only frompending(single use). A request is never revived; a new attempt is a new code.grant:nulluntil approved. Afterapprovedit isb64u(sealed box)sealed tox25519; it holds what the new device needs (cert, identity material, vault key). The relay stores it as opaque bytes (4 KiB cap,too_big) and never reads it. Adeniedrequest hasgrant: null.- After a decision the record lives 2 more minutes (so the new device can read it), then it is deleted.
Wire form for the device object written into the directory on approval is section 4.
https://codeaf.agentfield.ai/p/<code>#<k>
<k> is b64u(16 bytes), the link key of section 1. codeaf.agentfield.ai/p/* is a redirect/handoff page: it
sends the visitor to the installed app (codeaf://pair?code=<code>#<k>), or shows install and CLI steps.
The fragment never leaves the browser.
Printed or typed form, for headless use: <code>.<k> (for example k7m2q9xd.Qm9v...). The CLI prints
both the link and this token. Paste into any approve box is accepted in either shape.
Existing path, unchanged: a six-digit code with a nameplate (42-715-302) goes through /v1/pair and
the CPace exchange in internal/pair. The two paths are siblings, chosen by the shape of what is
entered:
| Entered text | Path |
|---|---|
^\d{1,4}-\d{3}-\d{3}$ or \d{3} \d{3} |
existing pairbox + CPace, no change |
^[0-9a-hjkmnp-tv-z]{8}([.#].+)?$ (case-insensitive) or a codeaf.agentfield.ai/p/ URL (the old codeaf.link host is not a codeaf link and is answered as such) |
link pairing, this file |
There is no secret in the short code, so link pairing has two guards instead of the PAKE: a human
approves on an already-paired device, and the approver sees check and the name. The grant is sealed
to the new device key, so the relay and anyone who reads the request learn nothing usable.
All paths are under the hosted relay origin. No route removes or changes an existing one.
| # | Method and path | Auth | Purpose |
|---|---|---|---|
| 1 | POST /v1/link/requests |
open | create a request |
| 2 | GET /v1/link/requests/{code} |
open | read one (approver and new device) |
| 3 | POST /v1/dir/requests/{code}/approve |
signed | approve |
| 4 | POST /v1/dir/requests/{code}/deny |
signed | deny |
| 5 | GET /v1/dir/presence |
signed | who is online now |
| 6 | GET /v1/link/limits |
open | numbers below, for clients |
Revoke is unchanged: POST /v1/dir/devices/{id}/revoke (204; revoked keys are refused as today).
Request:
{ "pubkey": "b64u", "x25519": "b64u", "name_sealed": "b64u", "platform": "linux" }Answer 201:
{ "code": "k7m2q9xd", "device": "dev_3Fq9...", "check": "4821",
"requested_at": 1790000000000, "expires_at": 1790000600000 }The client builds the link from code and its own k. Errors:
| Status | err | When |
|---|---|---|
| 400 | bad_request |
missing field, wrong key length, device mismatch, name over 96 bytes |
| 413 | too_big |
body over 1 KiB |
| 429 | rate_limited |
over 10 creates per hour per IP, or 3 pending requests at once per IP (Retry-After set) |
| 503 | full |
2000 live requests on the relay |
Answer 200 is the record of section 1 with state, grant, check, name_sealed, platform,
requested_at, expires_at, device, pubkey, x25519. Query ?wait=<ms> (max 25000) long-polls
until state leaves pending, answering 204 if the wait ends first (same behaviour as the mailbox
read). The new device uses it to wait for approval; the approver uses it once, without wait.
| Status | err | When |
|---|---|---|
| 404 | gone |
unknown, expired or deleted code (these are one answer on purpose) |
| 429 | rate_limited |
over 60 reads per minute per IP, or over 20 misses per minute per IP, or 4 open polls per IP |
Called by an already-paired device of the identity that is approving.
{
"device": {
"V": 1, "name": "b64 sealed under the metadata key", "added_by": "id_...",
"revoked": false, "caps": { "os": "linux", "arch": "arm64", "sandbox": "landlock",
"container": null, "gpu": null, "cow": "btrfs" },
"platform": "linux", "created": 1790000123456, "last_seen": 0
},
"cert": "b64u device cert for `dev_...` signed by the identity key (existing cert format)",
"grant": "b64u(sealed box to x25519)"
}Verify cert is for the request's device. Commit the device authorization, Device record,
sealed decision and delivery outbox together through the identity's Raft group; created uses
committed directory time. The directory version advances only after application. Deliver the
recorded approval and grant to the request object afterward, and emit joined (section 5).
Interrupted delivery resumes from the same recorded decision. An identical approve retries
idempotently and answers 204; no grant becomes visible before its authorization commits.
| Status | err | When |
|---|---|---|
| 400 | bad_request |
device/cert do not match the request |
| 401 | unauthorized |
not signed, or signer revoked (existing causes) |
| 404 | gone |
no such live request |
| 409 | already_decided |
request is approved for another body, or denied |
| 410 | rotated |
identity replaced (existing) |
| 413 | too_big |
grant over 4 KiB |
| 429 | rate_limited |
existing per-device limits |
Empty body. Sets denied; the waiting device reads state:"denied" and shows "Request declined".
Answer 204. Errors: 401 unauthorized, 404 gone, 409 already_decided (a repeat deny is 204).
Any paired device may approve or deny; the first decision wins.
{ "now": 1790000001000,
"devices": {
"dev_A": { "online": true, "last_seen": 1790000001000 },
"dev_B": { "online": false, "last_seen": 1789999000000 } } }One entry per non-revoked device in the committed directory, combined with advisory socket
presence. Authorization and directory rows pass a fresh majority read; sampling current committed
time can also write coordination state. Answers with Codeaf-Dir-Version like /v1/dir/list.
Compatibility clients use this on app open or when their watch is down. A joined Home follows
its local applied learner for display hints; taking or publishing still requires fresh authority.
{ "ttl_ms": 600000, "create_per_hour": 10, "pending_per_ip": 3, "read_per_minute": 60,
"miss_per_minute": 20, "concurrent_polls": 4, "max_grant": 4096, "max_live": 2000 }New keys in limits.js DEFAULTS (override via CAF_LIMITS as today): linkCreatePerHour 10,
linkPendingPerIp 3, linkReadPerMinute 60, linkMissPerMinute 20, linkConcurrentPolls 4,
linkMaxGrant 4096, linkMaxLive 2000. pairTtlMs is reused for the 10 minutes.
directory.Device gains three fields. Go (internal/directory/records.go):
Platform string `json:"platform,omitempty"` // darwin|linux|windows|ios|android|other
Created int64 `json:"created,omitempty"` // directory ms when the device joined; set by the relay
LastSeen int64 `json:"last_seen,omitempty"` // directory ms of the last watch socket close or helloFull JSON:
{ "V": 1, "name": "b64", "added_by": "id_...", "revoked": false,
"caps": { "os": "linux", "arch": "arm64", "sandbox": "landlock", "container": null, "gpu": null, "cow": "btrfs" },
"platform": "linux", "created": 1790000123456, "last_seen": 1790000999000 }Rules:
Vstays 1. Old readers ignore unknown fields; old records decode with zero values, shown as "unknown" platform and no time. A device written without the fields is valid.createdis set by the relay on first write of a device id and never changes afterward; a client value is ignored.last_seenis set only by the relay: at watch connect (hello) and at watch close. It is not written per ping, and a client value is ignored. An idle presence-only socket does not write ownership; a socket naming held chats still needs separate committed renewals and hold-until receipts.platformis client-supplied, same value set as section 1, written by self-registration or approve.- Rename and
capschanges keep usingPUT /v1/dir/devices/{id}(self only).Revokedis still set only by revoke. - Writing
last_seenbumps no directory version and sends no{"v":N}frame.
Today a watch socket gets only {"v":N} (version). That does not change for a socket opened as today.
A client opts in to events with a query flag: GET /v1/dir/watch?events=1 (plus existing hold
parameters). A socket without the flag never receives an event frame, so old clients cannot be
confused. With the flag the socket receives {"v":N} frames exactly as before, and in addition event
frames. An event frame has a string key t and no key v:
{"t":"joined","device":"dev_B","name":"b64 sealed","platform":"linux","at":1790000123456}
{"t":"presence","device":"dev_B","online":true,"at":1790000123999}
{"t":"presence","device":"dev_B","online":false,"at":1790000555000}
{"t":"revoked","device":"dev_B","at":1790000700000}joined: sent to every open event socket of the identity (the new device itself excluded) after the approval's device record and decision commit. The directory version also moves, so a client that only lists still sees it. The UI string is " is now paired", decoded fromnamewith the metadata key.presence: sent to the other event sockets when the first socket of a device opens (online:true) and when its last socket closes (online:false). A device is online iff it holds at least one open watch socket. A flapping device is debounced: anonline:falseis sent 15 s after the last socket closes unless a socket opens first, and noonline:trueis sent for a reconnect inside that gap. The close 15 s timer is a Durable Object alarm, set only when the last socket closes.- On connect an event socket first gets
{"v":N}then onepresenceframe per other online device, so it needs no/v1/dir/presencecall at start.presenceframes for offline devices are not sent on connect; use the list forlast_seen. revoked: informational; the close4401for the revoked device itself is unchanged.- Event frames are at most 512 bytes and are never queued for a socket that is gone. Clients must
ignore frames with an unknown
t.
- The relay never holds an unsealed device name, a grant's contents, or the link key.
- Approval needs a signed call by a non-revoked device of the identity. Open routes can only create, read and wait.
- A request decides once. Two approvers racing: one gets
204, the other409 already_decided. - Code guessing is bounded: 40 bits, 10 min, 20 misses per minute per IP, and a guessed code reveals a sealed name, a platform and a public key only.
- Revoked device ids are refused at the relay as today; a revoked device re-joins only as a new request and a new device id.
already_decided (409). Reused with the existing meaning: bad_request 400, unauthorized 401,
gone 404, too_big 413 (here, too_large for directory bodies stays), rate_limited 429, full 503,
rotated 410.
A nameplate is a short number, so it is drawn again some day. Two rules keep a device that still holds an old code from being answered by a newer mailbox that landed on the same nameplate.
- Quarantine. A relay does not draw a nameplate again until
plateQuarantineMsafter its mailbox ended (released or expired). The hosted relay's default is one pairing life (pairTtlMs, 10 minutes): a device may come back to a code until the code's life ends, and a poll that starts during the quarantine finds the old mailbox gone. Quarantined plates count as occupied when the nameplate length (2 to 4 digits) is chosen. - Generation. Each opening of a mailbox has a random generation, 32 hex characters, sent in the header
Codeaf-Pair-Genon every answer about that mailbox (create, post, poll). A device sends back the one it was told, on post, poll and delete. A mailbox that is a different opening answers404 gone, the same answer as for a mailbox that is not there.
The header is optional both ways. A request with none is not fenced, and a mailbox opened before generations existed, or a relay that names none (the self-hosted one), answers without it; a device then sends none. No body, status or word changes, so old and new builds keep working together. A joining device learns the generation from its first answer, so it is fenced from its second call on.
docs/testdata/ux-pairing-vectors.json holds the record shapes above as fixtures for the lanes
(request, approve body, presence answer, the four event frames, an old-style {"v":N} frame, and a
legacy Device record without the new fields).