diff --git a/apps/api/app/routes/steam.ts b/apps/api/app/routes/steam.ts index 5833a571..2dbb7626 100644 --- a/apps/api/app/routes/steam.ts +++ b/apps/api/app/routes/steam.ts @@ -23,12 +23,10 @@ export namespace SteamApi { content: { 'application/json': { schema: Result( - z - .union([LinkedAccount.Info, z.null()]) - .meta({ - description: 'The linked Steam account, or null', - example: Examples.LinkedAccount - }) + z.union([LinkedAccount.Info, z.null()]).meta({ + description: 'The linked Steam account, or null', + example: Examples.LinkedAccount + }) ) } }, @@ -53,9 +51,7 @@ export namespace SteamApi { 200: { content: { 'application/json': { - schema: Result( - z.object({ unlinked: z.boolean() }) - ) + schema: Result(z.object({ unlinked: z.boolean() })) } }, description: 'Steam account unlinked' @@ -80,76 +76,79 @@ export namespace SteamApi { ) .post( '/link', - describeRoute({ - tags: ['Steam'], - summary: 'Link a Steam account', - description: 'Link a Steam account to a user (admin) or yourself (user)', - responses: { - 200: { - content: { - 'application/json': { - schema: Result( - z.object({ - linkedAccountId: z.string().meta({ - description: 'The ID of the linked account', - example: Examples.LinkedAccount.id - }), - steamId: z.string().meta({ - description: 'The Steam ID that was linked', - example: '76561197960287930' + describeRoute({ + tags: ['Steam'], + summary: 'Link a Steam account', + description: 'Link a Steam account to a user (admin) or yourself (user)', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.object({ + linkedAccountId: z.string().meta({ + description: 'The ID of the linked account', + example: Examples.LinkedAccount.id + }), + steamId: z.string().meta({ + description: 'The Steam ID that was linked', + example: '76561197960287930' + }) }) - }) - ) - } + ) + } + }, + description: 'Steam account linked' }, - description: 'Steam account linked' - }, - 400: ErrorResponses[400], - 401: ErrorResponses[401], - 403: ErrorResponses[403], - 429: ErrorResponses[429] - } - }), - validator( - 'json', - z.object({ - steamId: z.string().min(1).meta({ - description: 'Steam ID to link', - example: '76561197960287930' - }), - userId: z.string().optional().meta({ - description: 'User ID to link to (admin only; omitted when linking your own account)', - example: 'usr_XXXXXXXXXXXXXXXXXXXXXXXXX' - }), - profile: z - .record(z.string(), z.unknown()) - .optional() - .meta({ - description: 'Steam profile data', - example: { personaname: 'Player', avatarfull: 'https://...' } - }) - }) - ), - async (c) => { - const body = c.req.valid('json'); - const actor = Actor.use(); + 400: ErrorResponses[400], + 401: ErrorResponses[401], + 403: ErrorResponses[403], + 429: ErrorResponses[429] + } + }), + validator( + 'json', + z.object({ + steamId: z.string().min(1).meta({ + description: 'Steam ID to link', + example: '76561197960287930' + }), + userId: z + .string() + .optional() + .meta({ + description: 'User ID to link to (admin only; omitted when linking your own account)', + example: Examples.Id('user') + }), + profile: z + .record(z.string(), z.unknown()) + .optional() + .meta({ + description: 'Steam profile data', + example: { personaname: 'Player', avatarfull: 'https://...' } + }) + }) + ), + async (c) => { + const body = c.req.valid('json'); + const actor = Actor.use(); - if (body.userId && actor.type !== 'admin') { - throw new VisibleError( - 'forbidden', - ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, - 'Only admin can link a Steam account for another user' - ); - } + if (body.userId && actor.type !== 'admin') { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Only admin can link a Steam account for another user' + ); + } - const linkedAccountID = await Steam.link({ - steamId: body.steamId, - profile: body.profile, - userId: body.userId - }); - return c.json({ - data: { linkedAccountId: linkedAccountID, steamId: body.steamId } - }); - } - ); + const linkedAccountID = await Steam.link({ + steamId: body.steamId, + profile: body.profile, + userId: body.userId + }); + return c.json({ + data: { linkedAccountId: linkedAccountID, steamId: body.steamId } + }); + } + ); } diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index 14043086..a7ab9612 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -378,7 +378,7 @@ The IDs are 30-char strings: `{prefix}_{26 base62 chars}`. They are monotonicall ```ts export namespace Examples { export const Id = (prefix: keyof typeof Identifier.prefixes) => - `${Identifier.prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`; + `${Identifier.prefixes[prefix]}_${'X'.repeat(Identifier.LENGTH)}`; export const User = { id: Id('user'), name: '…', email: '…', … }; export const LinkedAccount = { id: Id('linkedAccount'), provider: 'steam', … }; diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index 78234323..d4ac76f7 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -1,8 +1,12 @@ import { Identifier } from './id.js'; export namespace Examples { + // The width is taken from the generator rather than typed out. Counting + // twenty-six of anything by eye is a thing people get wrong once and then + // never look at again — this was one short, which made every documented id + // a value the schema that published it would reject. export const Id = (prefix: keyof typeof Identifier.prefixes) => - `${Identifier.prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`; + `${Identifier.prefixes[prefix]}_${'X'.repeat(Identifier.LENGTH)}`; export const User = { id: Id('user'), diff --git a/packages/core/src/id.test.ts b/packages/core/src/id.test.ts new file mode 100644 index 00000000..17ea4cd7 --- /dev/null +++ b/packages/core/src/id.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test'; + +import { Examples } from './examples.js'; +import { Identifier } from './id.js'; + +const prefixes = Object.keys(Identifier.prefixes) as (keyof typeof Identifier.prefixes)[]; + +describe('an id, the rule for one, and the documented example agree', () => { + // Three things have to say the same thing and only one of them is the + // generator. They drifted once already: the example was twenty-nine + // characters against a rule demanding thirty, so every id in the published + // documentation was a value the schema beside it would reject. Nothing + // noticed, because an example is never parsed. + + test('every generated id satisfies its own schema', () => { + for (const prefix of prefixes) { + const parsed = Identifier.schema(prefix).safeParse(Identifier.ascending(prefix)); + expect(parsed.success).toBe(true); + } + }); + + test('every documented example satisfies the schema that publishes it', () => { + for (const prefix of prefixes) { + const parsed = Identifier.schema(prefix).safeParse(Examples.Id(prefix)); + expect(parsed.success).toBe(true); + } + }); + + test('an id is the width the column holds', () => { + // `ulid()` is `char(26 + 4)`, and a char column refuses an overlong + // value rather than truncating — so a generator that drifted wider + // would fail every insert, not merely look wrong. + for (const prefix of prefixes) { + expect(Identifier.ascending(prefix)).toHaveLength(30); + expect(Examples.Id(prefix)).toHaveLength(30); + } + }); + + test('the schema refuses the near misses, not just the obvious ones', () => { + const schema = Identifier.schema('user'); + const body = 'a'.repeat(Identifier.LENGTH); + expect(schema.safeParse(`usr_${body}`).success).toBe(true); + // One short, one long, right length with the wrong prefix, and the + // prefix without its separator — which would otherwise read as a user + // id because it starts with the same three letters. + expect(schema.safeParse(`usr_${body.slice(1)}`).success).toBe(false); + expect(schema.safeParse(`usr_${body}a`).success).toBe(false); + expect(schema.safeParse(`mch_${body}`).success).toBe(false); + expect(schema.safeParse(`usr${body}a`).success).toBe(false); + }); +}); diff --git a/packages/core/src/id.ts b/packages/core/src/id.ts index 9773885f..1fcce66b 100644 --- a/packages/core/src/id.ts +++ b/packages/core/src/id.ts @@ -47,7 +47,14 @@ export namespace Identifier { .length(prefixes[prefix].length + 1 + LENGTH); } - const LENGTH = 26; + /** + * How many characters follow the prefix and separator. + * + * Exported because three things have to agree on it and two of them are + * not the generator: the column is fixed-width, {@link schema} refuses + * anything else, and the documented examples have to be values that pass. + */ + export const LENGTH = 26; let lastTimestamp = 0; let counter = 0;