[link] store share links

This commit is contained in:
Devin Zuczek
2026-10-20 12:22:52 -04:00
parent 6bf297ed98
commit 38bb9cff73
9 changed files with 314 additions and 2 deletions
+6
View File
@@ -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
}
+27
View File
@@ -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);
+2
View File
@@ -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"
},
+2
View File
@@ -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 */
+86
View File
@@ -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<string> {
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<LinkRow | null> {
return db.prepare('SELECT * FROM link WHERE code = ?1').bind(code).first<LinkRow>()
}
+46
View File
@@ -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<App>()
.use(
'*',
@@ -23,4 +40,33 @@ const app = new Hono<App>()
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<string, unknown>)
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
+133 -2
View File
@@ -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<Record<string, string>> {
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<string, string>) {
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}$/)
})
})
+9
View File
@@ -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.