diff --git a/apps/link/env.d.ts b/apps/link/env.d.ts index a27846d..db45f1a 100644 --- a/apps/link/env.d.ts +++ b/apps/link/env.d.ts @@ -10,3 +10,9 @@ declare namespace Cloudflare { mainModule: MainModule } } + +// Tests read the migration as a string and apply it, so the schema has one source. +declare module '*.sql?raw' { + const content: string + export default content +} diff --git a/apps/link/migrations/0001_link.sql b/apps/link/migrations/0001_link.sql new file mode 100644 index 0000000..7ac06cd --- /dev/null +++ b/apps/link/migrations/0001_link.sql @@ -0,0 +1,27 @@ +-- Share links (`POST /actionlink`): a short code standing for something in the game — a +-- room, a club, an event — that a player can hand to someone else. +-- +-- Each row is what the client sent, kept as sent: +-- code_type the link's kind, the `codeType` form field (3 club · 4 event · 5 room seen) +-- extra_data_id the id of the thing linked to, the `extraDataId` field (RoomId, ClubId, +-- PlayerEventId); NULL when the client sends none +-- data the `data` field verbatim: a JSON string `{ ExtraJson, Platform, Type }` +-- whose ExtraJson is itself an escaped JSON string. Stored opaque; nothing +-- here parses it +-- valid_hours the `validHours` field; `expires_at` is created_at plus that many hours +-- +-- `code` is the six-character code answered to the client, unique across every link. +-- A new code per request: asking twice for the same room gives two codes. + +CREATE TABLE IF NOT EXISTS link ( + code TEXT PRIMARY KEY, + creator_account_id INTEGER NOT NULL, + code_type INTEGER NOT NULL, + extra_data_id INTEGER, + data TEXT, + valid_hours INTEGER NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_link_target ON link (code_type, extra_data_id); diff --git a/apps/link/package.json b/apps/link/package.json index 3fb38ee..4301588 100644 --- a/apps/link/package.json +++ b/apps/link/package.json @@ -12,10 +12,12 @@ "deploy": "run-wrangler-deploy", "dev": "run-wrangler-dev", "fix:workers-types": "run-wrangler-types", + "migrate": "run-wrangler-migrate", "test": "run-vitest" }, "dependencies": { "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "hono": "4.13.5", "workers-tagged-logger": "1.0.1" }, diff --git a/apps/link/src/context.ts b/apps/link/src/context.ts index 6b549d3..c2c8a06 100644 --- a/apps/link/src/context.ts +++ b/apps/link/src/context.ts @@ -8,6 +8,8 @@ export type Env = SharedHonoEnv & { * `auth` verify here. */ JWT_SECRET: SecretsStoreSecret + /** Shared `recflare` D1 — this worker owns the `link` table. */ + DB: D1Database } /** Variables can be extended */ diff --git a/apps/link/src/link-db.ts b/apps/link/src/link-db.ts new file mode 100644 index 0000000..2d7f4a7 --- /dev/null +++ b/apps/link/src/link-db.ts @@ -0,0 +1,86 @@ +/** + * The `link` table (migrations/0001_link.sql): one row per share code minted by + * `POST /actionlink`. + */ + +/** + * The code alphabet: upper-case letters and digits without the look-alikes (0/O, 1/I/L), + * since a player may read a code aloud or type it from a screenshot. 31 symbols, so six of + * them give ~887 million codes. + */ +const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' +export const CODE_LENGTH = 6 + +/** How many fresh codes to try before giving up on a run of collisions. */ +const MAX_ATTEMPTS = 8 + +/** A random code. Rejection-sampled so every symbol is equally likely. */ +export function randomCode(): string { + // The largest multiple of the alphabet size that fits in a byte. + const limit = 256 - (256 % CODE_ALPHABET.length) + let code = '' + while (code.length < CODE_LENGTH) { + for (const byte of crypto.getRandomValues(new Uint8Array(CODE_LENGTH * 2))) { + if (byte >= limit) continue + code += CODE_ALPHABET[byte % CODE_ALPHABET.length] + if (code.length === CODE_LENGTH) break + } + } + return code +} + +export interface NewLink { + creatorAccountId: number + codeType: number + extraDataId: number | null + data: string | null + validHours: number +} + +export interface LinkRow { + code: string + creator_account_id: number + code_type: number + extra_data_id: number | null + data: string | null + valid_hours: number + created_at: string + expires_at: string +} + +/** + * Store a link under a fresh code and return the code. A code already taken is skipped + * (`ON CONFLICT DO NOTHING` returns no row) and another drawn. + */ +export async function createLink(db: D1Database, link: NewLink, now = new Date()): Promise { + const createdAt = now.toISOString() + const expiresAt = new Date(now.getTime() + link.validHours * 3_600_000).toISOString() + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const code = randomCode() + const row = await db + .prepare( + `INSERT INTO link (code, creator_account_id, code_type, extra_data_id, data, valid_hours, created_at, expires_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT (code) DO NOTHING + RETURNING code` + ) + .bind( + code, + link.creatorAccountId, + link.codeType, + link.extraDataId, + link.data, + link.validHours, + createdAt, + expiresAt + ) + .first<{ code: string }>() + if (row) return row.code + } + throw new Error(`no free link code after ${MAX_ATTEMPTS} attempts`) +} + +/** One link by code, or null. */ +export async function getLink(db: D1Database, code: string): Promise { + return db.prepare('SELECT * FROM link WHERE code = ?1').bind(code).first() +} diff --git a/apps/link/src/link.app.ts b/apps/link/src/link.app.ts index a562b1f..cf85a81 100644 --- a/apps/link/src/link.app.ts +++ b/apps/link/src/link.app.ts @@ -2,9 +2,26 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' +import { validateAndGetAccountId } from '@repo/jwt' + +import { createLink } from './link-db' import type { App } from './context' +/** + * The longest a link may live, in hours: ten years. The client asks for at most a year + * (8760, a room link); this only stops a hand-written request from minting a date that + * doesn't exist. + */ +const MAX_VALID_HOURS = 87_600 + +/** A form field as a whole integer, or null when it's absent or anything else. */ +function intField(value: unknown): number | null { + if (typeof value !== 'string' || !/^-?\d+$/.test(value.trim())) return null + const n = Number(value.trim()) + return Number.isSafeInteger(n) ? n : null +} + const app = new Hono() .use( '*', @@ -23,4 +40,33 @@ const app = new Hono() return c.text('hello, world!') }) + // Mint a share code for a room, club or event — the client's "share" link. A form body: + // data '{"ExtraJson":"{\"RoomId\":1162}","Platform":0,"Type":5}', stored verbatim + // validHours how long the code lives (8760 room · 2160 club · 8640 event seen) + // codeType the link's kind (5 room · 3 club · 4 event seen) + // extraDataId the id of the thing linked to + // Answers the code as a bare JSON string (`"ABC123"`), nothing else. Auth-gated: the + // row records who made it. + .post('/actionlink', async (c) => { + const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) + if (accountId === null) return c.body(null, 401) + + const body = await c.req.parseBody().catch(() => ({}) as Record) + const codeType = intField(body.codeType) + const validHours = intField(body.validHours) + if (codeType === null) return c.json({ error: 'codeType is required' }, 400) + if (validHours === null || validHours <= 0 || validHours > MAX_VALID_HOURS) { + return c.json({ error: 'validHours must be a positive number of hours' }, 400) + } + + const code = await createLink(c.env.DB, { + creatorAccountId: accountId, + codeType, + extraDataId: intField(body.extraDataId), + data: typeof body.data === 'string' ? body.data : null, + validHours, + }) + return c.json(code) + }) + export default app diff --git a/apps/link/src/test/integration/api.test.ts b/apps/link/src/test/integration/api.test.ts index 47bcb0a..0a86579 100644 --- a/apps/link/src/test/integration/api.test.ts +++ b/apps/link/src/test/integration/api.test.ts @@ -1,8 +1,139 @@ -import { SELF } from 'cloudflare:test' -import { expect, it } from 'vitest' +import { adminSecretsStore, env, SELF } from 'cloudflare:test' +import { beforeAll, describe, expect, it } from 'vitest' + +import linkSql from '../../../migrations/0001_link.sql?raw' +import { getLink, randomCode } from '../../link-db' + +import type { Env } from '../../context' + +declare module 'cloudflare:test' { + interface ProvidedEnv extends Env {} +} + +const ORIGIN = 'https://example.com' + +// Mint a token the way the `auth` worker does, signing with the shared test key seeded +// into the JWT_SECRET store. +const TEST_SECRET = 'test-signing-key' + +function b64url(input: ArrayBuffer | string): string { + const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +async function bearer(sub: number): Promise> { + const now = Math.floor(Date.now() / 1000) + const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( + JSON.stringify({ sub: String(sub), exp: now + 3600 }) + )}` + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(TEST_SECRET), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ) + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput)) + return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` } +} + +beforeAll(async () => { + await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET) + // Apply the migration itself, statement by statement, comments stripped. + const statements = linkSql + .replace(/--.*$/gm, '') + .split(';') + .map((s) => s.trim()) + .filter(Boolean) + for (const stmt of statements) await env.DB.prepare(stmt).run() +}) + +async function actionlink(caller: number | null, fields: Record) { + return SELF.fetch(`${ORIGIN}/actionlink`, { + method: 'POST', + headers: { + ...(caller === null ? {} : await bearer(caller)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(fields).toString(), + }) +} it('response with hello world', async () => { const res = await SELF.fetch('https://example.com') expect(res.status).toBe(200) expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`) }) + +describe('POST /actionlink', () => { + // The three bodies the client sends, verbatim. + const ROOM = { + data: '{"ExtraJson":"{\\u0022RoomId\\u0022:1162}","Platform":0,"Type":5}', + validHours: '8760', + codeType: '5', + extraDataId: '1162', + } + const CLUB = { + data: '{"ExtraJson":"{\\u0022ClubId\\u0022:4,\\u0022Joinability\\u0022:0,\\u0022Visibility\\u0022:1}","Platform":0,"Type":3}', + validHours: '2160', + codeType: '3', + extraDataId: '4', + } + const EVENT = { + data: '{"ExtraJson":"{\\u0022PlayerEventId\\u0022:174}","Platform":0,"Type":4}', + validHours: '8640', + codeType: '4', + extraDataId: '174', + } + + it('answers a bare JSON string code and stores what the client sent', async () => { + for (const body of [ROOM, CLUB, EVENT]) { + const before = Date.now() + const res = await actionlink(950001, body) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('application/json') + const text = await res.text() + const code = JSON.parse(text) as unknown + expect(typeof code).toBe('string') + expect(text).toBe(JSON.stringify(code)) + expect(code).toMatch(/^[A-Z2-9]{6}$/) + + const row = await getLink(env.DB, code as string) + expect(row).toMatchObject({ + creator_account_id: 950001, + code_type: Number(body.codeType), + extra_data_id: Number(body.extraDataId), + data: body.data, + valid_hours: Number(body.validHours), + }) + const lived = Date.parse(row!.expires_at) - Date.parse(row!.created_at) + expect(lived).toBe(Number(body.validHours) * 3_600_000) + expect(Date.parse(row!.created_at)).toBeGreaterThanOrEqual(before - 1000) + } + }) + + it('mints a new code each time', async () => { + const a = (await (await actionlink(950002, ROOM)).json()) as string + const b = (await (await actionlink(950002, ROOM)).json()) as string + expect(a).not.toBe(b) + }) + + it('401s without a token', async () => { + expect((await actionlink(null, ROOM)).status).toBe(401) + }) + + it('400s without a codeType or a usable validHours', async () => { + const { codeType: _codeType, ...noType } = ROOM + expect((await actionlink(950003, noType)).status).toBe(400) + expect((await actionlink(950003, { ...ROOM, validHours: '0' })).status).toBe(400) + expect((await actionlink(950003, { ...ROOM, validHours: 'soon' })).status).toBe(400) + }) +}) + +describe('randomCode', () => { + it('draws six symbols with no look-alikes', () => { + for (let i = 0; i < 200; i++) expect(randomCode()).toMatch(/^[A-HJKMNP-Z2-9]{6}$/) + }) +}) diff --git a/apps/link/wrangler.jsonc b/apps/link/wrangler.jsonc index ba6115e..7afbf2c 100644 --- a/apps/link/wrangler.jsonc +++ b/apps/link/wrangler.jsonc @@ -4,6 +4,15 @@ "main": "src/link.app.ts", "compatibility_date": "2026-06-16", "compatibility_flags": ["nodejs_compat"], + "d1_databases": [ + { + "binding": "DB", + "database_name": "recflare", + "database_id": "local", + "migrations_dir": "migrations", + "migrations_table": "d1_migrations_link" + } + ], // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86cf6dd..21fa0ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -736,6 +736,9 @@ importers: '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers + '@repo/jwt': + specifier: workspace:* + version: link:../../packages/jwt hono: specifier: 4.13.5 version: 4.13.5