From fe5297acbdbc9e2ce63c87f1e720b1ade426a920 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 6 Sep 2026 13:57:12 +0300 Subject: [PATCH] fix(core): document an id that is actually a valid id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example generator emitted twenty-five payload characters where an id has twenty-six, so every documented id was twenty-nine characters — one short of the width the column holds and, since last commit, one short of what the schema publishing it will accept. Nothing caught it because an example is never parsed: it is copied into documentation and read by people. The width now comes from the generator's own constant instead of being typed out, in the two places that had counted it by hand. Counting twenty-six of anything by eye is a thing people get right once and never re-check. A test pins the three together — a generated id, the schema for one, and the documented example must all agree, for every prefix. It fails on the off-by-one that prompted this, and on a prefix without its separator, which would otherwise read as an id of that type because it starts with the same three letters. --- apps/api/app/routes/steam.ts | 153 +++++++++++++++++----------------- packages/core/CLAUDE.md | 2 +- packages/core/src/examples.ts | 6 +- packages/core/src/id.test.ts | 51 ++++++++++++ packages/core/src/id.ts | 9 +- 5 files changed, 141 insertions(+), 80 deletions(-) create mode 100644 packages/core/src/id.test.ts 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;