Connect > Identities gains 'Generate link code': a short single-use code
(10-min expiry, sha-256 stored) that the device redeems — POST
/api/keys/link {code} or the new MCP link_account tool — merging the
device's account into the code-minting account. The gh-auth-login pattern:
the browser session authorizes the target, holding the key's bearer token
authorizes the source; nobody pastes signatures into a console. The signed
programmatic path remains as fallback.- commit
- 1ac43a1714b671b73fcdfd33a90a5406ed854661
- parent
- 3a41373036cc65a2c50216406e33e5e4e835e31f
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 24 | ++++++++----- |
| M | docs/ | 1 | + |
| A | migrations/ | 10 | +++++ |
| M | packages/ | 15 | +++++++- |
| M | web/ | 1 | + |
| M | web/ | 41 | +++++++++++++++++++++ |
| M | worker/ | 59 | ++++++++++++++++++++++++++++++ |
| M | worker/ | 1 | + |
| M | worker/ | 4 | ++- |
| M | worker/ | 15 | ++++++++ |
| M | worker/ | 25 | +++++++++++-- |
| M | worker/ | 12 | ++++++ |
| M | worker/ | 2 | +- |
| M | worker/ | 42 | +++++++++++++++++++++- |
@@ -89,20 +89,26 @@ async function main() {
8989 main();9090 ```9191 92-## Linking a shoo account (optional)92+## Linking accounts (optional)9393 94-Signing in with shoo on the website gives a browser session. To attach an95-existing keypair identity to that account (so both credentials are one96-user):94+The easy path is a **pairing code** (like `gh auth login`): on the website,95+Connect > Identities > "Generate link code" shows a short single-use code96+(10-minute expiry). On the device, redeem it — the device's account merges97+into the code's account:9798 9899 ```99-POST <origin>/api/keys/link (with the shoo session cookie)100-{ "publicKey": "...", "ts": ..., "sig": "..." } (same proof format)100+POST <origin>/api/keys/link (with the device's bearer token)101+{ "code": "mesa-otter-glyph-a41f" }101102 ```102103 103-Everything the keypair user owned — scripts, sims, stars, hearts, tokens —104-moves to the shoo-linked user, and the keypair keeps working as a105-credential for it. Linking is one-way and idempotent.104+or over MCP: `link_account {"code": "..."}`. Everything the device account105+owned — scripts, sims, stars, hearts — moves over, and the keypair keeps106+working as a credential for the merged account.107+108+The programmatic alternative (no code, browser session required): POST the109+standard signed proof to the same endpoint while the shoo session cookie is110+present — `{publicKey, ts, sig}` — which merges the KEY's account into the111+signed-in one. Both directions are one-way and idempotent.106112 107113 ## Adding a device from the website108114 @@ -82,6 +82,7 @@ Example client config:
8282 | `browse_scripts` | `{sort?, window?, lab?, openWeights?, releasedAfter?, releasedBefore?, q?, limit?, offset?}` | `PublicScriptListing[]` — the public Browse directory |8383 | `get_public_script` | `{id}` | `PublicScriptDetail` — listing + proof sims + aggregates (+ `source` iff shared) |8484 | `get_leaderboard` | `{window?, limit?}` | `LeaderboardRow[]` — Elo boards; see "Sharing & the fishbowl leaderboard" |85+| `link_account` | `{code}` | Merge THIS device's account into the account that minted the pairing code (Connect tab, single-use, 10 min). |8586 | `list_models` | `{q?, lab?, openWeights?, limit?}` | `ModelRecord[]` — the models catalog (synced daily from models.dev) |8687 | `create_sim` | `{scriptIds: string[] (2-8), seed?}` | `SimRecord` incl. share `url` and `expiresAt`. Each id: your own validated script, a hearted listed script, or an example (`ex_<name>`). Bundles publish locked sources — fork an example or shared source to use someone else's idea. |8788 | `get_sim` | `{id}` | `SimRecord` (metadata only; no script sources) |@@ -0,0 +1,10 @@
1+-- Device-pairing link codes: the signed-in browser mints a short-lived,2+-- single-use code; the device (key-holder) redeems it to merge its account3+-- into the code's user. Only the sha-256 of the code is stored.4+CREATE TABLE link_codes (5+ code_hash TEXT PRIMARY KEY,6+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,7+ created_at INTEGER NOT NULL,8+ expires_at INTEGER NOT NULL9+);10+CREATE INDEX idx_link_codes_user ON link_codes(user_id);@@ -235,6 +235,11 @@ export const LeaderboardInput = z.object({
235235 limit: z.number().int().min(1).max(100).default(25),236236 });237237 238+export const LinkAccountInput = z.object({239+ /** Device-pairing code from the Connect tab (single-use, 10-min expiry). */240+ code: z.string().min(4).max(64),241+});242+238243 export const ListModelsInput = z.object({239244 q: z.string().max(120).optional(),240245 lab: z.string().max(64).optional(),@@ -251,6 +256,7 @@ export type HeartInputT = z.infer<typeof HeartInput>;
251256 export type BrowseInputT = z.infer<typeof BrowseInput>;252257 export type LeaderboardInputT = z.infer<typeof LeaderboardInput>;253258 export type ListModelsInputT = z.infer<typeof ListModelsInput>;259+export type LinkAccountInputT = z.infer<typeof LinkAccountInput>;254260 255261 // ---------------------------------------------------------------------------256262 // Service interface — implemented once in worker/, used by REST + MCP@@ -292,6 +298,11 @@ export interface TribesService {
292298 setSharing(user: AuthedUser, input: SetSharingInputT): Promise<UserInfo>;293299 /** REST-only by design (no MCP tool). Requires confirm === user id. */294300 deleteAccount(user: AuthedUser, confirm: string): Promise<void>;301+ /** Merge the calling (device) account into the code-minting account. */302+ linkAccount(303+ user: AuthedUser,304+ input: LinkAccountInputT,305+ ): Promise<{ linked: boolean; alreadyLinked: boolean }>;295306 296307 heart(user: AuthedUser, input: HeartInputT): Promise<void>;297308 unheart(user: AuthedUser, input: HeartInputT): Promise<void>;@@ -341,7 +352,9 @@ export interface TribesService {
341352 * GET /api/sims/:id/bundle -> SimBundle (PUBLIC, no auth)342353 * POST /api/keys/auth {publicKey, ts, sig} -> {token, user} (PUBLIC — mints343354 * the device's wire token, replacing its previous one)344- * POST /api/keys/link same proof, authed — merge keypair identity into account355+ * POST /api/keys/link {code} (device side) or same proof (browser side), authed —356+ * merge accounts357+ * POST /api/keys/link-code authed -> {code, expiresAt} — device-pairing code (10 min, single-use)345358 * GET /api/keys -> KeyIdentity[] (authorized keys)346359 * POST /api/keys/add {publicKey} — grant this account to a key you hold347360 * DELETE /api/keys {publicKey} — revoke (never the last identity)@@ -160,6 +160,7 @@ export const api = {
160160 161161 // tokens162162 listKeys: () => req<KeyIdentity[]>('GET', '/api/keys'),163+ mintLinkCode: () => req<{ code: string; expiresAt: string }>('POST', '/api/keys/link-code'),163164 addKey: (publicKey: string) => req<{ added: boolean }>('POST', '/api/keys/add', { publicKey }),164165 removeKey: (publicKey: string) => req<void>('DELETE', '/api/keys', { publicKey }),165166 };@@ -316,6 +316,47 @@ function paintIdentities(card: HTMLElement) {
316316 'Add device key',317317 ),318318 ),319+ h('h3', { style: 'margin-top:1.2rem' }, 'Link a device'),320+ h(321+ 'p',322+ { class: 'muted' },323+ 'Already minted a key on another machine (or as an agent)? Generate a pairing code and redeem it there - the device account merges into this one.',324+ ),325+ (() => {326+ const codeOut = h('div');327+ return h(328+ 'div',329+ null,330+ h(331+ 'button',332+ {333+ onclick: async () => {334+ clear(codeOut);335+ try {336+ const { code, expiresAt } = await api.mintLinkCode();337+ codeOut.append(338+ h(339+ 'div',340+ { class: 'token-once' },341+ h('p', null, 'On the device, redeem within 10 minutes:'),342+ h('p', { class: 'row' }, h('code', { class: 'mono' }, code)),343+ h(344+ 'p',345+ { class: 'muted' },346+ `MCP: link_account {"code": "${code}"} - or POST /api/keys/link with it. Expires ${fmtDate(expiresAt)}.`,347+ ),348+ ),349+ );350+ } catch (err) {351+ codeOut.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));352+ }353+ },354+ },355+ 'Generate link code',356+ ),357+ codeOut,358+ );359+ })(),319360 h('h3', { style: 'margin-top:1.2rem' }, 'Connect your agent'),320361 h(321362 'pre',@@ -84,6 +84,65 @@ export async function keyAuth(
8484 return { token: value, tokenId: id, user };8585 }8686 87+// ---------------------------------------------------------------------------88+// Link codes (device pairing): the browser mints a code, the device redeems89+// it. Words keep codes typo-robust and speakable.90+// ---------------------------------------------------------------------------91+92+const CODE_WORDS = [93+ 'amber', 'birch', 'cairn', 'delta', 'ember', 'fjord', 'glyph', 'heron',94+ 'inlet', 'joust', 'krill', 'lumen', 'mesa', 'nadir', 'otter', 'pike',95+ 'quill', 'ridge', 'sable', 'tarn', 'umber', 'vale', 'wren', 'yarrow',96+] as const;97+export const LINK_CODE_TTL = 600; // seconds98+99+export function generateLinkCode(): string {100+ const idx = crypto.getRandomValues(new Uint32Array(3));101+ const tail = crypto.getRandomValues(new Uint8Array(2));102+ const hex = Array.from(tail, (b) => b.toString(16).padStart(2, '0')).join('');103+ return `${CODE_WORDS[idx[0]! % 24]}-${CODE_WORDS[idx[1]! % 24]}-${CODE_WORDS[idx[2]! % 24]}-${hex}`;104+}105+106+/** Mint a fresh code for the user (replacing any outstanding one). */107+export async function mintLinkCode(env: Env, userId: string): Promise<{ code: string; expiresAt: number }> {108+ const code = generateLinkCode();109+ const now = nowSec();110+ await env.DB.batch([111+ env.DB.prepare(`DELETE FROM link_codes WHERE user_id = ?1`).bind(userId),112+ env.DB113+ .prepare(`INSERT INTO link_codes (code_hash, user_id, created_at, expires_at) VALUES (?1, ?2, ?3, ?4)`)114+ .bind(await hashToken(code), userId, now, now + LINK_CODE_TTL),115+ ]);116+ return { code, expiresAt: now + LINK_CODE_TTL };117+}118+119+/** Redeem a code: returns the target user id and consumes the code. */120+export async function redeemLinkCode(env: Env, code: string): Promise<string | null> {121+ const hash = await hashToken(code.trim().toLowerCase());122+ const row = await env.DB123+ .prepare(`DELETE FROM link_codes WHERE code_hash = ?1 AND expires_at > ?2 RETURNING user_id`)124+ .bind(hash, nowSec())125+ .first<{ user_id: string }>();126+ return row?.user_id ?? null;127+}128+129+/**130+ * Merge the CALLING user (a device/key account, proven by its bearer token)131+ * into the account that minted the code. The device side needs no session132+ * and no extra signature — holding the token IS proof of the key.133+ */134+export async function linkByCode(135+ env: Env,136+ caller: AuthedUser,137+ code: string,138+): Promise<'linked' | 'already' | 'invalid'> {139+ const targetId = await redeemLinkCode(env, code);140+ if (targetId === null) return 'invalid';141+ if (targetId === caller.userId) return 'already';142+ await db.mergeUsers(env.DB, caller.userId, targetId);143+ return 'linked';144+}145+87146 /**88147 * Merge the keypair identity into the signed-in account. Everything the89148 * key-user owned moves over; the key identity re-points to the target user@@ -92,6 +92,7 @@ Example client config:
9292 | \`browse_scripts\` | \`{sort?, window?, lab?, openWeights?, releasedAfter?, releasedBefore?, q?, limit?, offset?}\` | \`PublicScriptListing[]\` — the public Browse directory |9393 | \`get_public_script\` | \`{id}\` | \`PublicScriptDetail\` — listing + proof sims + aggregates (+ \`source\` iff shared) |9494 | \`get_leaderboard\` | \`{window?, limit?}\` | \`LeaderboardRow[]\` — Elo boards; see "Sharing & the fishbowl leaderboard" |95+| \`link_account\` | \`{code}\` | Merge THIS device's account into the account that minted the pairing code (Connect tab, single-use, 10 min). |9596 | \`list_models\` | \`{q?, lab?, openWeights?, limit?}\` | \`ModelRecord[]\` — the models catalog (synced daily from models.dev) |9697 | \`create_sim\` | \`{scriptIds: string[] (2-8), seed?}\` | \`SimRecord\` incl. share \`url\` and \`expiresAt\`. Each id: your own validated script, a hearted listed script, or an example (\`ex_<name>\`). Bundles publish locked sources — fork an example or shared source to use someone else's idea. |9798 | \`get_sim\` | \`{id}\` | \`SimRecord\` (metadata only; no script sources) |@@ -92,6 +92,7 @@ function makeService(): TribesService {
9292 createSim: vi.fn(async () => SIM),9393 listSims: vi.fn(async () => [SIM]),9494 getSimBundle: vi.fn(async () => BUNDLE),95+ linkAccount: vi.fn(async () => ({ linked: true, alreadyLinked: false })),9596 };9697 }9798 @@ -211,7 +212,7 @@ describe('createMcpFetchHandler', () => {
211212 expect(res.headers.get('mcp-session-id')).toBeNull();212213 });213214 214- it('lists all 23 tools without any prior initialize handshake', async () => {215+ it('lists all 24 tools without any prior initialize handshake', async () => {215216 const { fetch } = makeHandler();216217 const { res, body } = await invoke(fetch, rpcRequest({ method: 'tools/list' }));217218 expect(res.status).toBe(200);@@ -222,6 +223,7 @@ describe('createMcpFetchHandler', () => {
222223 'create_sim',223224 'delete_logic',224225 'get_example',226+ 'link_account',225227 'get_leaderboard',226228 'get_logic',227229 'get_public_script',@@ -22,6 +22,7 @@ import {
2222 HeartInput,2323 LeaderboardInput,2424 LIMITS,25+ LinkAccountInput,2526 ListModelsInput,2627 MCP_ROUTE,2728 SetSharingInput,@@ -487,6 +488,20 @@ function buildServer(service: TribesService, user: AuthedUser, docsText: string)
487488 }),488489 );489490 491+ server.registerTool(492+ 'link_account',493+ {494+ title: 'Link this device to a site account',495+ description:496+ 'Redeem a device-pairing code (minted on the Connect tab, single-use, 10-minute expiry) to merge ' +497+ "THIS device's account into the account that minted the code. Everything owned here — scripts, sims, " +498+ 'stars, hearts — moves over, and this key keeps authenticating as the merged account. Ask your human ' +499+ 'for the code shown under Connect > Identities > "link a device".',500+ inputSchema: LinkAccountInput,501+ },502+ ({ code }) => run(() => service.linkAccount(user, { code })),503+ );504+490505 return server;491506 }492507 @@ -53,7 +53,7 @@ import {
5353 import { createService, ServiceError } from './service';5454 import type { ServiceDeps } from './service';5555 import { base64UrlToBytes, isoOf, nowSec } from './util';56-import { keyAuth, linkKey, parseKeyProof } from './keys';56+import { keyAuth, linkByCode, linkKey, mintLinkCode, parseKeyProof } from './keys';5757 5858 export interface ApiDeps {5959 validate: ServiceDeps['validate'];@@ -351,10 +351,21 @@ export async function handleApi(
351351 if (!user) return err('unauthorized', 'authentication required', 401);352352 const service = createService(env, { validate: deps.validate, origin: url.origin, background: deps.background });353353 354- // Link a device keypair into the signed-in account (one-way, idempotent).354+ // Link accounts. Two shapes:355+ // {code} — device side: merge the CALLING (key) account356+ // into the account that minted the code.357+ // {publicKey, ts, sig} — browser side: merge the key's account into358+ // the signed-in one (programmatic fallback).355359 if (path === '/api/keys/link' && method === 'POST') {356- const proof = parseKeyProof(await readJson(request));357- if (proof === null) return err('bad_input', 'expected {publicKey, ts, sig}', 400);360+ const body = await readJson(request);361+ const maybeCode = (body as Record<string, unknown> | null)?.['code'];362+ if (typeof maybeCode === 'string') {363+ const outcome = await linkByCode(env, user, maybeCode);364+ if (outcome === 'invalid') return err('bad_input', 'invalid or expired link code', 400);365+ return json({ linked: true, alreadyLinked: outcome === 'already' });366+ }367+ const proof = parseKeyProof(body);368+ if (proof === null) return err('bad_input', 'expected {code} or {publicKey, ts, sig}', 400);358369 const outcome = await linkKey(env, user, proof, url.hostname);359370 if (outcome === 'invalid') {360371 return err('unauthorized', 'invalid key proof (check the signing string, host, and clock)', 401);@@ -362,6 +373,12 @@ export async function handleApi(
362373 return json({ linked: true, alreadyLinked: outcome === 'already' });363374 }364375 376+ // Mint a device-pairing code (shown in Connect; redeemed by the device).377+ if (path === '/api/keys/link-code' && method === 'POST') {378+ const minted = await mintLinkCode(env, user.userId);379+ return json({ code: minted.code, expiresAt: isoOf(minted.expiresAt) }, 201);380+ }381+365382 if (path === '/api/me') {366383 if (method === 'GET') {367384 const row = await getUserById(env.DB, user.userId);@@ -72,6 +72,7 @@ import { friendlyId } from './friendly-id';
7272 import { computeSimMetrics } from './simrun';7373 import { normalizeSource } from './normalize';7474 import { getSourceAnywhere, putSource, resolveSources } from './r2blobs';75+import { linkByCode } from './keys';7576 import { sanitizeMeta } from './sanitize';7677 import { exampleHashes, getExample } from './examples';7778 @@ -452,6 +453,17 @@ export function createService(env: Env, deps: ServiceDeps): TribesService {
452453 await D.batch(stmts);453454 },454455 456+ async linkAccount(457+ user: AuthedUser,458+ input: { code: string },459+ ): Promise<{ linked: boolean; alreadyLinked: boolean }> {460+ const outcome = await linkByCode(env, user, input.code);461+ if (outcome === 'invalid') {462+ throw new ServiceError('bad_input', 'invalid or expired link code');463+ }464+ return { linked: true, alreadyLinked: outcome === 'already' };465+ },466+455467 async heart(user: AuthedUser, input: HeartInputT): Promise<void> {456468 const row = await db.getScriptById(D, input.id, user.userId);457469 if (!row) throw new ServiceError('not_found', `script ${input.id} not found`);@@ -9,7 +9,7 @@ import { DatabaseSync } from 'node:sqlite';
99 import { readFileSync } from 'node:fs';1010 import { fileURLToPath } from 'node:url';1111 12-const MIGRATIONS = ['0001_init.sql', '0002_sim_metrics.sql', '0003_social.sql', '0004_r2_metrics.sql', '0005_keypair_auth.sql'];12+const MIGRATIONS = ['0001_init.sql', '0002_sim_metrics.sql', '0003_social.sql', '0004_r2_metrics.sql', '0005_keypair_auth.sql', '0006_link_codes.sql'];1313 1414 type Param = string | number | null | Uint8Array;1515 @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest';
66 import type { Env } from '../src/env';77 import { createFakeD1, createFakeR2 } from './fake-d1';88 import * as db from '../src/db';9-import { keyAuth, linkKey, verifyKeyProof, type KeyProof } from '../src/keys';9+import { keyAuth, linkByCode, linkKey, mintLinkCode, verifyKeyProof, type KeyProof } from '../src/keys';1010 import { authenticateBearer } from '../src/auth';1111 import { nowSec } from '../src/util';1212 @@ -130,3 +130,43 @@ describe('linkKey', () => {
130130 ).toBe('already');131131 });132132 });133+134+describe('link codes (device pairing)', () => {135+ it('mint -> redeem merges the device account; codes are single-use', async () => {136+ const { env, D } = makeEnv();137+ const key = await makeKey();138+ const device = await keyAuth(env, await proofFor(key), HOST);139+ const site = await db.upsertUser(D, 'shoo:site', 'dami');140+141+ const { code } = await mintLinkCode(env, site.id);142+ const caller = { userId: device!.user.id, externalId: device!.user.external_id };143+ expect(await linkByCode(env, caller, code)).toBe('linked');144+ // Device account merged away; key now resolves to the site user.145+ expect(await db.getUserById(D, device!.user.id)).toBeNull();146+ const again = await keyAuth(env, await proofFor(key), HOST);147+ expect(again!.user.id).toBe(site.id);148+ // Single use.149+ expect(await linkByCode(env, caller, code)).toBe('invalid');150+ });151+152+ it('rejects unknown and self-redeemed codes sanely', async () => {153+ const { env, D } = makeEnv();154+ const site = await db.upsertUser(D, 'shoo:solo', null);155+ const me = { userId: site.id, externalId: site.external_id };156+ expect(await linkByCode(env, me, 'wrong-code-0000')).toBe('invalid');157+ const { code } = await mintLinkCode(env, site.id);158+ expect(await linkByCode(env, me, code)).toBe('already'); // own code: no-op159+ });160+161+ it('a new mint replaces the outstanding code', async () => {162+ const { env, D } = makeEnv();163+ const site = await db.upsertUser(D, 'shoo:rotate', null);164+ const key = await makeKey();165+ const device = await keyAuth(env, await proofFor(key), HOST);166+ const caller = { userId: device!.user.id, externalId: device!.user.external_id };167+ const first = await mintLinkCode(env, site.id);168+ const second = await mintLinkCode(env, site.id);169+ expect(await linkByCode(env, caller, first.code)).toBe('invalid');170+ expect(await linkByCode(env, caller, second.code)).toBe('linked');171+ });172+});