From fd4f81eaf357980da26d3c26853da8e4681de0dc Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 16:40:06 -0500 Subject: [PATCH] specs: the switchboard invite relay surface and the DNS it needs rt's invite client, the app's mattstack://join deep link, and team.join are all built and speak a protocol nothing serves. The deployed switchboard runs PR #1's peer-boards surface, which MAT-379 names as the substrate for the team registry -- the registry was never built on top, so every rt invite call 404s, and switchboard.mattstack.dev has no DNS record at all. Neither spec invents anything. The endpoint contract is read off lib/team/relay-client.ts, which documents every status code it branches on: 409 on create means "pick a new id", 409 on redeem means the race was lost, 404 on delete is success because revocation is idempotent. The schema is deliberately the security argument. MAT-379 ruling 4 promises that a full DB dump yields opaque ids, ciphertext and timestamps -- no remotes, no rosters, no employer fingerprint -- so the spec carries a test asserting the column list, making that ruling fail a test rather than a review. Scope note: this is a fraction of MAT-379 sub-project A. No team definition blobs, no per-team owner credentials, no membership. Just the relay. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QMy7FiR4bcTt8GTNdmWALS --- .../specs/2026-08-23-mattstack-dev-dns.md | 104 ++++++++ .../2026-08-23-switchboard-v1-invites.md | 233 ++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-23-mattstack-dev-dns.md create mode 100644 docs/superpowers/specs/2026-08-23-switchboard-v1-invites.md diff --git a/docs/superpowers/specs/2026-08-23-mattstack-dev-dns.md b/docs/superpowers/specs/2026-08-23-mattstack-dev-dns.md new file mode 100644 index 00000000..053fb86b --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-mattstack-dev-dns.md @@ -0,0 +1,104 @@ +# `mattstack.dev` — the DNS the invite flow needs + +**Status:** ready to apply +**Owner:** Matt (Cloudflare account holds the zone; nothing configured yet) +**Blocks:** the entire "you've been invited" flow + +Every hostname below is already hardcoded or specified somewhere in the estate. +None of them resolve today, which is why an invite cannot be minted or redeemed. + +## Required now — the invite flow + +### 1. `switchboard.mattstack.dev` → the Railway relay + +rt's `DEFAULT_INVITE_RELAY_URL` (`lib/team/relay-client.ts:12`). Without it, +every `rt team invite` and every join fails at DNS. + +| | | +|---|---| +| Type | `CNAME` | +| Name | `switchboard` | +| Target | `switchboard-production-cda9.up.railway.app` | +| Proxy | **DNS only (grey cloud)** | +| TTL | Auto | + +**Grey cloud, not orange.** Proxying gives Cloudflare the TLS session, which +means Cloudflare terminates and can see request bodies. The relay's whole +promise (MAT-379 ruling 4) is that the operator cannot read what it stores — +the ciphertext is opaque to Cloudflare either way, but adding a second party +who sees traffic metadata weakens the claim we make to a user pointing a work +account at this. It also means Railway's own certificate is what clients +validate, one less moving part. + +Railway also needs the custom domain registered on its side (service settings → +Networking → Custom Domain) or it will not serve a certificate for the name. +Add it there first, then create the CNAME it prints. + +**After it resolves**, one check that catches the common mistake: + +``` +curl -sS https://switchboard.mattstack.dev/healthz # expect: ok +``` + +If that returns Cloudflare's HTML error page rather than `ok`, the custom +domain was not registered on Railway. + +### 2. `mattstack.dev/join` → the invite landing page + +Specified in the installer design (§6, and ruling 3a): a **static** page at +`https://mattstack.dev/join#` that reads the fragment client-side and +offers Download plus "Open in mattstack" (`mattstack://join/`). + +The fragment is the load-bearing detail. **A URL fragment is never sent to the +server** — that is why the invite key rides there rather than in a query +string. Two consequences that must survive implementation: + +- The page must be **static**. Any server-side rendering that reflects the + fragment defeats the design. +- No analytics, no third-party scripts. A script with DOM access can read + `location.hash` and ship the team key anywhere. + +Simplest hosting: **Cloudflare Pages** on the same zone. + +| | | +|---|---| +| Type | `CNAME` (created by Pages when you add the custom domain) | +| Name | `@` (apex) or `www` | +| Target | the `*.pages.dev` project | +| Proxy | Orange cloud is fine here — it is a static page holding no secrets | + +The apex record differs from the switchboard one on purpose: a static marketing +page benefits from Cloudflare's cache and has nothing to leak; the relay does +not and does. + +Nothing consumes this page programmatically, so it can land after the relay. +The invite code still works by paste (`TeamScreen` accepts the raw code) — the +landing page is the nicety that makes a link clickable for someone who does not +have the app yet. + +## Specified but not yet needed + +Recorded so they are not rediscovered later. Neither blocks invites. + +- **`deck.mattstack.dev`** — deck's curl installer (`deck-7`, unshipped). + Referenced in research inventory, not in shipping code. +- **`install.mattstack.dev`** — MAT-360's installer front door + (`curl -fsSL install.mattstack.dev | sh -s -- `). MAT-379 ruling 2 + parameterizes it with the invite. Still Backlog. + +## Order to do it in + +1. Register the custom domain on the Railway service. +2. Create the `switchboard` CNAME, grey cloud. +3. `curl https://switchboard.mattstack.dev/healthz` → `ok`. +4. **Only then** is it worth implementing `/v1/invites` — the spec beside this + one — because until the name resolves there is no way to test it end to end. + +The landing page can happen any time after 3. + +## One caveat worth stating plainly + +Pointing `switchboard.mattstack.dev` at the relay makes invites *reachable*, not +*working*. The deployed relay does not serve `/v1/invites` yet, so rt will get +404s instead of DNS failures. That is progress but not a working flow — see +`2026-08-23-switchboard-v1-invites.md` for the surface that has to exist. diff --git a/docs/superpowers/specs/2026-08-23-switchboard-v1-invites.md b/docs/superpowers/specs/2026-08-23-switchboard-v1-invites.md new file mode 100644 index 00000000..7bf6c889 --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-switchboard-v1-invites.md @@ -0,0 +1,233 @@ +# Switchboard `/v1/invites` — the team-invite relay surface + +**Status:** spec, ready to implement +**Owner:** board lane (`m4ttstack/board`, `switchboard/`) +**Consumer:** rt (`lib/team/relay-client.ts`) — already written to this contract +**Authority:** MAT-379 rulings 3 + 4; `docs/superpowers/specs/2026-08-20-mattstack-app-installer-design.md` §6 (line 463) and ruling 3a + +## Why this exists + +`rt team invite` and `team.join` are built and speak this protocol today. The +deployed switchboard serves PR #1's peer-boards surface (`/boards`, +`/envelopes`, `/inbox`), which MAT-379 names as *the substrate* for the team +registry — the registry itself was never built on top. Every rt invite call +currently 404s. + +This spec is the minimum surface that makes the invite flow work end to end. It +is deliberately **not** all of MAT-379 sub-project A: no team definition blobs, +no per-team owner credentials, no membership. Those layer on later. What is +here is the relay, and only the relay. + +## The invariant that shapes everything + +MAT-379 ruling 4, restated by the installer design as: **no field on the relay +is ever plaintext employer data.** + +A full DB dump must yield opaque ids, ciphertext, an opaque creator secret hash, +and timestamps. Nothing else. Specifically the relay never sees, and must never +be given a column for: + +- the team's git remote, forge host, or owner handle +- the team name or slug +- the invitee's handle +- anything derived from the above + +All of that lives inside `ciphertext`, sealed client-side. The decryption key +travels in the invite code (and the landing page's URL fragment) and **never +reaches the server**. The relay cannot read what it stores, by construction — +that is the durable answer to "can I point my work account at infrastructure +Matt runs". + +A reviewer should be able to check this by reading the schema alone. + +## Endpoints + +Base: `https://switchboard.mattstack.dev` (rt's `DEFAULT_INVITE_RELAY_URL`; +overridable per-machine with `RT_INVITE_RELAY_URL`). + +No admin token. These are unauthenticated by design — an invite id is a +128-bit secret, and requiring a shared token would mean shipping one to every +prospective member before they have joined anything. + +### `POST /v1/invites` + +Body: `{ ciphertext: string, expiresAt: string, id?: string }` +`expiresAt` is ISO-8601 (rt sends `new Date(...).toISOString()`). + +The client supplies `id` because the id is the sealed blob's AAD — it must be +fixed *before* the ciphertext can exist. The relay stores under that id; it +does not mint one. + +| Condition | Response | +|---|---| +| stored | `201 {id, creatorSecret}` | +| `id` already exists | **`409`** — never overwrite, never return the existing record's `creatorSecret` | +| `id` not 32 lowercase hex | `400` | +| `ciphertext` missing/not a string | `400` | +| `expiresAt` missing/unparseable/in the past | `400` | +| body over the size cap | `413` | + +`id` MUST match `/^[0-9a-f]{32}$/` — rt asserts this client-side and will +reject anything else, so a relay that accepts other shapes creates records rt +can never read back. + +The 409 is load-bearing: rt maps it to `relay-id-conflict`, distinct from a +generic failure, because the fix is a fresh id rather than a retry. + +`creatorSecret`: ≥32 bytes from a CSPRNG, returned **once**, at creation. +Store only a hash of it (see Storage). + +### `GET /v1/invites/:id` + +| Condition | Response | +|---|---| +| live and unredeemed | `200 {ciphertext}` | +| unknown id | `404` | +| expired, redeemed, or revoked | `410` | + +rt treats 404 and 410 identically ("gone"), so the distinction is for +operators, not clients. Never return `expiresAt`, a redeemed flag, or any +other field: an unauthenticated GET should not confirm *why* an id is gone. + +### `POST /v1/invites/:id/redeem` + +Single-use, and the atomicity here is the whole point. + +| Condition | Response | +|---|---| +| this caller won | `200` | +| already redeemed by anyone | **`409`** | +| unknown / expired | `404` / `410` | + +MUST be a compare-and-set in one SQL statement — `UPDATE … WHERE id = ? AND +redeemed_at IS NULL`, then branch on rows-changed. A read-then-write is a race +two simultaneous redeemers can both win, which would hand one invite to two +machines. The existing peer-boards code already does this correctly for its own +invites (`098fe84`, "redeem the invite and register the board in one +transaction") — reuse that shape. + +### `POST /v1/invites/:id/reply` + +Body: `{ blob: string }`. The joiner posts their age public key here, sealed. + +| Condition | Response | +|---|---| +| stored | `200` | +| a reply already exists | `409` — write-once | +| unknown / expired | `404` / `410` | + +Unauthenticated *write*, authenticated *read* — deliberate. The joiner holds no +credential at this point; they have only the invite code. Write-once is what +stops a third party who somehow learns the id from overwriting the real +joiner's key. + +### `GET /v1/invites/:id/reply` + +`Authorization: Bearer ` + +| Condition | Response | +|---|---| +| reply present, secret matches | `200 {blob}` | +| no reply yet | `404` | +| secret missing or wrong | `401` | + +rt polls this; `404` means "not yet", not an error. + +### `DELETE /v1/invites/:id` + +`Authorization: Bearer ` + +| Condition | Response | +|---|---| +| revoked | `204` | +| already gone | `404` — rt treats this as **success** | +| secret missing or wrong | `401` | + +Revocation is idempotent by contract: rt's `delete()` returns normally on 404, +because an invite the relay already reaped is revoked as far as the caller is +concerned. + +## Storage + +```sql +CREATE TABLE invites ( + id TEXT PRIMARY KEY, -- 32 lowercase hex, client-chosen + ciphertext TEXT NOT NULL, -- opaque; the relay never parses this + creator_secret_hash TEXT NOT NULL, -- sha256 of the secret, never the secret + expires_at INTEGER NOT NULL, -- epoch ms + created_at INTEGER NOT NULL, + redeemed_at INTEGER, -- NULL until redeemed; the CAS column + reply_blob TEXT, -- opaque; write-once + reply_at INTEGER +); +CREATE INDEX invites_expires_at ON invites (expires_at); +``` + +That is the whole schema, and its shape is the security argument: there is no +column an operator could read to learn who invited whom, to what, or where. + +- `creator_secret_hash`, never the secret. Compare with a constant-time + comparison over the hashes. +- Reuse the existing `/data/switchboard.sqlite` volume; this is a new table + alongside the peer-boards ones, not a new service. +- Prune on the existing hourly `store.prune()` timer: delete where + `expires_at < now`. Redeemed invites are pruned on the same schedule rather + than immediately, so `410` stays distinguishable from `404` for a while. + +## Limits + +- `ciphertext` and `blob`: cap at 64 KB each. A team pointer is a few hundred + bytes; anything near the cap is misuse. Over → `413`. +- Rate-limit `POST /v1/invites` per source IP. The installer design says + "rate-limited" without a number; **10/minute** is ample for a human minting + invites and closes bulk-storage abuse of an unauthenticated endpoint. +- Rate-limit failed `Authorization` attempts per id to make `creatorSecret` + guessing pointless. + +## Tests worth having + +The existing `switchboard/__tests__/server.test.ts` is the pattern. Beyond +happy paths: + +1. **Two concurrent redeems of one invite: exactly one 200, one 409.** Drive + them genuinely in parallel — a sequential test cannot fail even with a + read-then-write race, which makes it a non-test. +2. **`POST` with an existing id returns 409 and does not alter the stored + record** — assert the original ciphertext and `creatorSecret` still work + afterwards, not just the status code. +3. **A wrong `creatorSecret` cannot read a reply or delete** — 401, and the + record survives. +4. **Reply is write-once** — second post 409, first blob intact. +5. **Expired invite**: GET 410, redeem 410, and it disappears after prune. +6. **Schema check**: assert the column list matches the table above, so a later + change that adds a plaintext field fails a test rather than shipping. This + is the one that enforces ruling 4 mechanically instead of by review. + +## Deployment + +Railway service `switchboard` in project `mattstack-switchboard`, built from +`switchboard/Dockerfile` in `m4ttstack/board`. + +**Check the deploy actually moves.** The ACTIVE deployment as of 2026-08-23 is +`45de6c7` (PR #1) while `098fe84` is the newest commit touching `switchboard/`, +and later deploys show *"No changes to watched files."* Confirm the watch paths +include `switchboard/**` and `src/peer/**` before trusting a green deploy — a +relay that silently serves old code is exactly the class of failure this +program has hit repeatedly. + +Also note the Dockerfile copies only `switchboard/*.ts` + `src/peer/envelope.ts`. +A new import outside those paths will build fine locally and fail in the image. + +## Verification, end to end + +The unit tests do not prove the flow. After deploy: + +``` +rt team invite --handle --team # mints against the live relay +# then, on a second machine (or a VM): +rt setup intent join → rt setup apply +``` + +Then confirm on the relay that the stored row contains no team name, no remote, +and no handle. That check is the point of the whole design; run it once against +real data rather than assuming.