clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

feat: device-pairing link codes — no more console-paste account linking

Dami committed 1ac43a1
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>
14 files changed, +235 -17
Mdocs/auth.md24++++++++-----
Mdocs/llms.txt1+
Amigrations/0006_link_codes.sql10+++++
Mpackages/protocol/src/api.ts15+++++++-
Mweb/src/api.ts1+
Mweb/src/pages/connect.ts41+++++++++++++++++++++
Mworker/src/keys.ts59++++++++++++++++++++++++++++++
Mworker/src/mcp/docs.ts1+
Mworker/src/mcp/handler.test.ts4++-
Mworker/src/mcp/handler.ts15++++++++
Mworker/src/router.ts25+++++++++++--
Mworker/src/service.ts12++++++
Mworker/test/fake-d1.ts2+-
Mworker/test/keys.test.ts42+++++++++++++++++++++-
M docs/auth.md +15 -9
@@ -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
M docs/llms.txt +1 -0
A migrations/0006_link_codes.sql +10 -0
M packages/protocol/src/api.ts +14 -1
@@ -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)
M web/src/api.ts +1 -0
M web/src/pages/connect.ts +41 -0
M worker/src/keys.ts +59 -0
@@ -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
M worker/src/mcp/docs.ts +1 -0
M worker/src/mcp/handler.test.ts +3 -1
M worker/src/mcp/handler.ts +15 -0
M worker/src/router.ts +21 -4
@@ -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);
M worker/src/service.ts +12 -0
M worker/test/fake-d1.ts +1 -1
M worker/test/keys.test.ts +41 -1
@@ -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+});