Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 0
specs: the switchboard invite relay surface and the DNS it needs#43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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#<code>` that reads the fragment client-side and | ||
| offers Download plus "Open in mattstack" (`mattstack://join/<code>`). | ||
| 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 -- <invite>`). 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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
Comment on lines
+52
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Make The body declares 🤖 Prompt for AI Agents | ||
| | 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. | ||
Comment on lines
+80
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win Do not expose operator-only state through an unauthenticated status code. The specification says the 404/410 distinction is for operators and should not confirm why an ID is gone. An unauthenticated caller can still observe that distinction. Return one status for gone IDs, or state that the distinction is intentionally observable. 🤖 Prompt for AI Agents | ||
| ### `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. | ||
Comment on lines
+102
to
+107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Include expiration in the redemption compare-and-set. The required predicate checks only 🤖 Prompt for AI Agents | ||
| ### `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. | ||
Comment on lines
+109
to
+122
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Make reply write-once storage atomic. The specification requires write-once replies but does not require a conditional SQL write. Concurrent posts can both succeed or overwrite a reply if implementation uses read-then-write logic. Require an atomic insert/update such as 🤖 Prompt for AI Agents | ||
| ### `GET /v1/invites/:id/reply` | ||
| `Authorization: Bearer <creatorSecret>` | ||
| | 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 <creatorSecret>` | ||
| | 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 | ||
| ); | ||
Comment on lines
+152
to
+162
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Represent revocation in storage or change the status contract. The endpoint contract promises 🤖 Prompt for AI Agents | ||
| 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. | ||
Comment on lines
+177
to
+185
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Bound Creation rejects only past timestamps. An attacker can submit 64 KB ciphertexts with far-future expiration and grow SQLite indefinitely; distributed sources can bypass the per-IP limit. Require a maximum invite lifetime and a storage quota or retention policy. 🤖 Prompt for AI Agents | ||
| ## 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 <someone> --team <slug> # mints against the live relay | ||
| # then, on a second machine (or a VM): | ||
| rt setup intent join <code> → 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. | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make the apex host mandatory for the documented join URL.
The required URL is
https://mattstack.dev/join#<code>, but this table permitswwwinstead of@. Awwwrecord alone does not serve the apex URL. Specify@as the Pages custom domain, or define and verify a separate apex redirect that preserves the fragment.🤖 Prompt for AI Agents