From 9641bf0a6bd0d81e41dd1b0b5417ebe9049d1cbf Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Tue, 22 Sep 2026 09:36:55 +0100 Subject: [PATCH 1/4] fix(ids)!: mint the id prefixes the spec documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine entries in ID_PREFIXES used a prefix the OpenAPI spec (v0.80.0) never shows in its own `id` examples — abbreviations the emulator invented, such as `evt_` where the spec documents `event_` and `ra_` where it documents `role_assignment_`. Response shape conformance cannot see this: a field set says nothing about what goes in the field, so a wrong prefix passes every existing spec check while breaking any consumer that string-matches an id. Prefixes changed, each against the spec example it was read from: event evt -> event invitation inv -> invitation directory_group directory_grp -> directory_group cors_origin cors -> cors_origin authorization_resource auth_res -> authz_resource role_assignment ra -> role_assignment audit_log_export audit_export -> audit_log_export authorized_application auth_app -> authorized_connect_app radar_attempt radar_attempt -> radar_att The radar attempt is the one with no object-level example: its prefix comes from the `/radar/attempts/{id}` parameter example and `RadarStandaloneResponse.attempt_id`, which agree on `radar_att_`. So this cannot drift back, gen-shapes now extracts the spec's per-object id prefix into ID_PREFIX_REQUIREMENTS, discovered structurally rather than from a curated map, and src/workos/id-prefixes.spec.ts asserts ID_PREFIXES matches it. Objects the spec gives no example for, and prefixes that knowingly differ, live in ledgers there with a reason each — closing one fails until its entry is deleted. BREAKING CHANGE: ids minted for events, invitations, directory groups, CORS origins, authorization resources, role assignments, audit log exports, authorized connect applications and radar attempts now carry the prefix the WorkOS API documents. Tests and fixtures that hardcode or pattern-match the old prefixes (`evt_`, `inv_`, `directory_grp_`, `cors_`, `auth_res_`, `ra_`, `audit_export_`, `auth_app_`, `radar_attempt_`) need updating. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/gen-routes-lib.ts | 4 +- scripts/gen-shapes-lib.spec.ts | 85 +++++ scripts/gen-shapes-lib.ts | 209 +++++++++- scripts/gen-shapes.ts | 7 +- src/core/id.ts | 23 +- src/workos/generated/response-shapes.ts | 359 +++++++++++++++++- src/workos/id-prefixes.spec.ts | 118 ++++++ src/workos/response-shapes.spec.ts | 2 +- src/workos/routes/audit-logs.spec.ts | 2 +- .../routes/authorization-checks.spec.ts | 2 +- .../routes/authorization-resources.spec.ts | 4 +- src/workos/routes/config.spec.ts | 2 +- src/workos/routes/invitations.spec.ts | 2 +- src/workos/routes/radar.spec.ts | 2 +- 14 files changed, 789 insertions(+), 32 deletions(-) create mode 100644 src/workos/id-prefixes.spec.ts diff --git a/scripts/gen-routes-lib.ts b/scripts/gen-routes-lib.ts index 7623e5a..8d78b5d 100644 --- a/scripts/gen-routes-lib.ts +++ b/scripts/gen-routes-lib.ts @@ -148,9 +148,9 @@ const KNOWN_PREFIXES: Record = { sso_authorization: 'sso_auth', directory: 'directory', directory_user: 'directory_user', - directory_group: 'directory_grp', + directory_group: 'directory_group', event: 'event', - invitation: 'inv', + invitation: 'invitation', }; /** Base entity fields that are auto-managed — excluded from generated fields. */ diff --git a/scripts/gen-shapes-lib.spec.ts b/scripts/gen-shapes-lib.spec.ts index ee8a595..11d21cd 100644 --- a/scripts/gen-shapes-lib.spec.ts +++ b/scripts/gen-shapes-lib.spec.ts @@ -5,6 +5,7 @@ import { extractEnvelope, parseShapeCatalog, parseEnvelopeCatalog, + parseIdPrefixCatalog, generateShapesFile, type ShapeMapEntry, type EnvelopeMapEntry, @@ -226,6 +227,82 @@ describe('parseEnvelopeCatalog', () => { }); }); +describe('parseIdPrefixCatalog', () => { + /** A schema with an `object` discriminator and an example id. */ + function resource(objectType: string, example: string): EventSchemaNode { + return { + type: 'object', + properties: { object: { type: 'string', const: objectType }, id: { type: 'string', example } }, + } as unknown as EventSchemaNode; + } + + it('extracts the prefix from each object id example', () => { + const s = spec({ Widget: resource('widget', 'widget_01HXYZ123456789ABCDEFGHIJ') }); + expect(parseIdPrefixCatalog(s)).toEqual([ + { + objectType: 'widget', + prefix: 'widget', + example: 'widget_01HXYZ123456789ABCDEFGHIJ', + source: 'Widget', + conflicts: [], + }, + ]); + }); + + it("accepts examples that are not valid Crockford Base32 — the spec's contain I and U", () => { + const s = spec({ Widget: resource('widget', 'widget_01HXYZ123456789ABCDEFGHIJ') }); + expect(parseIdPrefixCatalog(s)[0].prefix).toBe('widget'); + }); + + it('resolves an `object` discriminator that sits in a different allOf member than `id`', () => { + const s = spec({ + Base: { type: 'object', properties: { object: { type: 'string', const: 'widget' } } }, + Widget: { + allOf: [ + { $ref: '#/components/schemas/Base' }, + { type: 'object', properties: { id: { type: 'string', example: 'wg_01HXYZ123456789ABCDEFGHIJ' } } }, + ], + } as unknown as EventSchemaNode, + }); + expect(parseIdPrefixCatalog(s)).toEqual([ + { objectType: 'widget', prefix: 'wg', example: 'wg_01HXYZ123456789ABCDEFGHIJ', source: 'Widget', conflicts: [] }, + ]); + }); + + it('finds an object whose only example is in an inline schema nested inside a list', () => { + const s = spec({ + WidgetList: { + type: 'object', + properties: { data: { type: 'array', items: resource('widget', 'widget_01HXYZ123456789ABCDEFGHIJ') } }, + } as unknown as EventSchemaNode, + }); + expect(parseIdPrefixCatalog(s).map((e) => e.objectType)).toEqual(['widget']); + }); + + it('prefers the shallowest example where the spec contradicts itself, and keeps the loser visible', () => { + const s = spec({ + Widget: resource('widget', 'widget_01HXYZ123456789ABCDEFGHIJ'), + EventSchema: { + type: 'object', + properties: { + data: { type: 'object', properties: { widget: resource('widget', 'wg_01HXYZ123456789ABCDEFGHIJ') } }, + }, + } as unknown as EventSchemaNode, + }); + const [entry] = parseIdPrefixCatalog(s); + expect(entry.prefix).toBe('widget'); + expect(entry.conflicts).toEqual(['wg']); + }); + + it('ignores an id example with no prefix, and a schema with no object discriminator', () => { + const s = spec({ + Bare: { type: 'object', properties: { id: { type: 'string', example: '01HXYZ123456789ABCDEFGHIJ' } } }, + Anonymous: { type: 'object', properties: { id: { type: 'string', example: 'wg_01HXYZ123456789ABCDEFGHIJ' } } }, + }); + expect(parseIdPrefixCatalog(s)).toEqual([]); + }); +}); + describe('generateShapesFile', () => { const out = generateShapesFile( [{ objectType: 'widget', schemaName: 'Widget', properties: ['id', 'object'], required: ['id'] }], @@ -237,6 +314,7 @@ describe('generateShapesFile', () => { required: ['widget'], }, ], + [{ objectType: 'widget', prefix: 'wg', example: 'wg_01HXYZ123', source: 'Widget', conflicts: ['widget'] }], ); it('emits a RESPONSE_SHAPE_REQUIREMENTS record keyed by object type', () => { @@ -251,4 +329,11 @@ describe('generateShapesFile', () => { expect(out).toContain("'POST /widgets/validations': {"); expect(out).toContain("schema: 'WidgetValidation'"); }); + + it('emits an ID_PREFIX_REQUIREMENTS record keyed by object type', () => { + expect(out).toContain('export const ID_PREFIX_REQUIREMENTS'); + expect(out).toContain("prefix: 'wg'"); + expect(out).toContain("example: 'wg_01HXYZ123'"); + expect(out).toContain("conflicts: ['widget']"); + }); }); diff --git a/scripts/gen-shapes-lib.ts b/scripts/gen-shapes-lib.ts index c8404cf..d2cef60 100644 --- a/scripts/gen-shapes-lib.ts +++ b/scripts/gen-shapes-lib.ts @@ -5,7 +5,7 @@ * Extracts response *shapes* (property + required field sets) from a WorkOS * OpenAPI spec and generates src/workos/generated/response-shapes.ts. * - * Two catalogs, because a response body has two layers that can drift apart: + * Three catalogs, because a response body has layers that can drift apart: * * 1. OBJECT_SCHEMA_MAP — the *resource* objects (`user`, `api_key`, ...), * keyed by the emulator's `object` discriminator. Covers what the @@ -16,6 +16,10 @@ * see them — and an envelope is assembled inline in the route handler, * which is exactly where a plausible-looking invention like `{ valid }` * slips past a spec that says `{ api_key }`. + * 3. The ID prefix catalog — the `id` *value* format per object. Field sets + * say nothing about what goes in them, so `id: "ra_01…"` where the spec + * documents `role_assignment_01…` passes both catalogs above. This one is + * discovered structurally (see parseIdPrefixCatalog), not curated. * * Unlike the event catalog — discovered structurally via properties.event.const * — resource schemas are neither uniformly named nor uniformly shaped in the @@ -356,7 +360,170 @@ export function parseEnvelopeCatalog( return map.map((entry) => extractEnvelope(entry, spec)).sort((a, b) => a.operation.localeCompare(b.operation)); } -export function generateShapesFile(shapes: ParsedShape[], envelopes: ParsedEnvelope[]): string { +export interface ParsedIdPrefix { + /** The `object` discriminator the example's schema declares, e.g. "role_assignment". */ + objectType: string; + /** The prefix the example's id carries, e.g. "role_assignment". */ + prefix: string; + /** The example the prefix was read from, verbatim. */ + example: string; + /** The top-level spec schema (or path) the example was found under. */ + source: string; + /** Other prefixes the spec also uses for this object's id, where it contradicts itself. */ + conflicts: string[]; +} + +/** + * A prefixed example id. Deliberately not a ULID pattern: the spec's examples are + * not all valid Crockford Base32 (`authorized_connect_app_01HXYZ123456789ABCDEFGHIJ` + * contains I and U), and some are short (`we_0123456789`). Greedy prefix matching is + * what resolves `authz_resource_01HXYZ…` to `authz_resource` rather than `authz`. + */ +const ID_EXAMPLE_RE = /^([a-z][a-z0-9_]*)_([0-9A-Za-z]{6,})$/; + +interface PrefixOccurrence extends Omit { + /** Nesting depth below the top-level schema — the tie-break for the canonical example. */ + depth: number; +} + +/** + * The named property as declared anywhere in a node's composition. `object` and `id` are + * routinely declared in different allOf/oneOf members (an `EventSchema` variant carries + * the discriminator; the member beside it carries the id), so neither can be read off the + * node's own `properties` alone. Branches are searched in declaration order; `seen` guards + * ref cycles. + */ +function compositionProperty( + node: EventSchemaNode, + spec: EventSchemaNode, + name: string, + seen = new Set(), +): EventSchemaNode | undefined { + if (node.$ref) { + const target = node.$ref.match(/^#\/components\/schemas\/(.+)$/)?.[1]; + if (!target || seen.has(target)) return undefined; + seen.add(target); + const resolved = getSchemas(spec)[target]; + return resolved ? compositionProperty(resolved, spec, name, seen) : undefined; + } + + const own = node.properties?.[name]; + if (own) return own; + + for (const key of ['allOf', 'oneOf', 'anyOf'] as const) { + for (const member of (node[key] as EventSchemaNode[] | undefined) ?? []) { + const found = compositionProperty(member, spec, name, new Set(seen)); + if (found) return found; + } + } + return undefined; +} + +/** The `object` discriminator for a node, looked for across every branch of its composition. */ +function objectDiscriminator(node: EventSchemaNode, spec: EventSchemaNode): string | undefined { + const field = compositionProperty(node, spec, 'object'); + if (!field) return undefined; + const resolved = field.$ref ? resolveSchema(field, spec) : field; + const value = resolved.const ?? (resolved.enum?.length === 1 ? resolved.enum[0] : undefined); + return typeof value === 'string' ? value : undefined; +} + +/** Every string example declared on a property, across `example` and `examples`. */ +function exampleStrings(node: EventSchemaNode): string[] { + const out: string[] = []; + if (typeof node.example === 'string') out.push(node.example); + const examples = node.examples; + if (Array.isArray(examples)) { + for (const value of examples) if (typeof value === 'string') out.push(value); + } else if (examples && typeof examples === 'object') { + for (const entry of Object.values(examples as Record)) { + const value = entry && typeof entry === 'object' ? (entry as { value?: unknown }).value : entry; + if (typeof value === 'string') out.push(value); + } + } + return out; +} + +/** + * Walk everything under `node`, recording each `id` example found on a schema that also + * declares an `object` discriminator. The walk is structural rather than $ref-following: + * an object's id example can sit in an inline schema nested inside a list wrapper + * (`AuthorizedConnectApplicationList.data.items`), which no curated schema map would reach. + */ +function collectIdPrefixes( + node: unknown, + spec: EventSchemaNode, + source: string, + depth: number, + out: PrefixOccurrence[], + seen: Set, +): void { + if (!node || typeof node !== 'object') return; + if (seen.has(node)) return; + seen.add(node); + + if (Array.isArray(node)) { + for (const item of node) collectIdPrefixes(item, spec, source, depth + 1, out, seen); + return; + } + + const schema = node as EventSchemaNode; + const idField = compositionProperty(schema, spec, 'id'); + if (idField) { + const objectType = objectDiscriminator(schema, spec); + if (objectType) { + for (const example of exampleStrings(idField)) { + const match = ID_EXAMPLE_RE.exec(example); + if (match) out.push({ objectType, prefix: match[1], example, source, depth }); + } + } + } + + for (const value of Object.values(schema)) collectIdPrefixes(value, spec, source, depth + 1, out, seen); +} + +/** + * The spec's documented id prefix per object, extracted from its own `id` examples. + * + * Nothing is curated here: every object the spec gives a prefixed `id` example for is + * catalogued. Where the spec contradicts itself the canonical example is the shallowest + * one — the resource schema rather than an event payload's nested copy — with the losing + * prefixes kept in `conflicts` so the disagreement stays visible rather than being picked + * silently. Ties break on source name, so the output is stable across runs. + */ +export function parseIdPrefixCatalog(spec: EventSchemaNode): ParsedIdPrefix[] { + const occurrences: PrefixOccurrence[] = []; + const seen = new Set(); + for (const [name, schema] of Object.entries(getSchemas(spec))) { + collectIdPrefixes(schema, spec, name, 0, occurrences, seen); + } + const paths = (spec as { paths?: Record }).paths ?? {}; + for (const [path, item] of Object.entries(paths)) { + collectIdPrefixes(item, spec, path, 0, occurrences, seen); + } + + const byObject = new Map(); + for (const occurrence of occurrences) { + const list = byObject.get(occurrence.objectType); + if (list) list.push(occurrence); + else byObject.set(occurrence.objectType, [occurrence]); + } + + return [...byObject.entries()] + .map(([objectType, list]) => { + const ranked = [...list].sort((a, b) => a.depth - b.depth || a.source.localeCompare(b.source)); + const canonical = ranked[0]; + const conflicts = [...new Set(ranked.map((o) => o.prefix))].filter((p) => p !== canonical.prefix).sort(); + return { objectType, prefix: canonical.prefix, example: canonical.example, source: canonical.source, conflicts }; + }) + .sort((a, b) => a.objectType.localeCompare(b.objectType)); +} + +export function generateShapesFile( + shapes: ParsedShape[], + envelopes: ParsedEnvelope[], + idPrefixes: ParsedIdPrefix[], +): string { const lines: string[] = []; lines.push('/**'); lines.push(' * Generated by scripts/gen-shapes.ts — do not edit by hand.'); @@ -368,9 +535,12 @@ export function generateShapesFile(shapes: ParsedShape[], envelopes: ParsedEnvel lines.push(' * - RESPONSE_SHAPE_REQUIREMENTS per resource (OBJECT_SCHEMA_MAP)'); lines.push(' * - RESPONSE_ENVELOPE_REQUIREMENTS per operation (ENVELOPE_SCHEMA_MAP)'); lines.push(' *'); - lines.push(' * Consumed by src/workos/response-shapes.spec.ts and'); - lines.push(' * src/workos/response-envelopes.spec.ts to assert the emulator matches the'); - lines.push(' * spec and never leaks internal fields.'); + lines.push(" * Plus ID_PREFIX_REQUIREMENTS, discovered structurally from the spec's own"); + lines.push(' * `id` examples rather than from a curated map.'); + lines.push(' *'); + lines.push(' * Consumed by src/workos/response-shapes.spec.ts,'); + lines.push(' * src/workos/response-envelopes.spec.ts and src/workos/id-prefixes.spec.ts to'); + lines.push(' * assert the emulator matches the spec and never leaks internal fields.'); lines.push(' */'); lines.push(''); lines.push('export interface ResponseShapeRequirement {'); @@ -411,5 +581,34 @@ export function generateShapesFile(shapes: ParsedShape[], envelopes: ParsedEnvel } lines.push('};'); lines.push(''); + lines.push('export interface IdPrefixRequirement {'); + lines.push(" /** The prefix the spec's canonical `id` example carries, without the trailing underscore. */"); + lines.push(' prefix: string;'); + lines.push(' /** The example it was read from, verbatim. */'); + lines.push(' example: string;'); + lines.push(' /** The top-level spec schema (or path) the example was found under. */'); + lines.push(' source: string;'); + lines.push(' /** Other prefixes the spec uses for this object elsewhere, where it contradicts itself. */'); + lines.push(' conflicts: readonly string[];'); + lines.push('}'); + lines.push(''); + lines.push('/**'); + lines.push(' * The id prefix the spec documents for each object, keyed by `object` discriminator.'); + lines.push(' * Covers every object the spec gives a prefixed `id` example for, including ones the'); + lines.push(' * emulator does not model — src/workos/id-prefixes.spec.ts matches it against'); + lines.push(' * ID_PREFIXES and ledgers what is left over.'); + lines.push(' */'); + lines.push('export const ID_PREFIX_REQUIREMENTS: Record = {'); + for (const entry of idPrefixes) { + const conflicts = entry.conflicts.map((c) => `'${c}'`).join(', '); + lines.push(` ${entry.objectType}: {`); + lines.push(` prefix: '${entry.prefix}',`); + lines.push(` example: '${entry.example}',`); + lines.push(` source: '${entry.source}',`); + lines.push(` conflicts: [${conflicts}],`); + lines.push(' },'); + } + lines.push('};'); + lines.push(''); return lines.join('\n'); } diff --git a/scripts/gen-shapes.ts b/scripts/gen-shapes.ts index 8c8bc4e..2ef7b91 100644 --- a/scripts/gen-shapes.ts +++ b/scripts/gen-shapes.ts @@ -26,7 +26,7 @@ import YAML from 'yaml'; import { format, type FormatConfig } from 'oxfmt'; import { type EventSchemaNode } from './gen-events-lib.js'; -import { parseShapeCatalog, parseEnvelopeCatalog, generateShapesFile } from './gen-shapes-lib.js'; +import { parseShapeCatalog, parseEnvelopeCatalog, parseIdPrefixCatalog, generateShapesFile } from './gen-shapes-lib.js'; /** Load the project's oxfmt config so generated output matches `npm run fmt`. */ function loadFormatConfig(): FormatConfig { @@ -66,9 +66,10 @@ async function main(): Promise { const shapes = parseShapeCatalog(spec); const envelopes = parseEnvelopeCatalog(spec); + const idPrefixes = parseIdPrefixCatalog(spec); const resolvedOut = resolve(outFile); // The output path's `.ts` extension tells oxfmt to use the TypeScript parser. - const formatted = await format(resolvedOut, generateShapesFile(shapes, envelopes), loadFormatConfig()); + const formatted = await format(resolvedOut, generateShapesFile(shapes, envelopes, idPrefixes), loadFormatConfig()); if (formatted.errors.length > 0) { console.error('oxfmt reported errors while formatting generated output:'); for (const err of formatted.errors) console.error(` ${err.severity}: ${err.message}`); @@ -84,7 +85,7 @@ async function main(): Promise { mkdirSync(dirname(resolvedOut), { recursive: true }); writeFileSync(resolvedOut, content, 'utf-8'); console.log(` wrote ${resolvedOut}`); - console.log(`\nShapes: ${shapes.length} resources, ${envelopes.length} envelopes`); + console.log(`\nShapes: ${shapes.length} resources, ${envelopes.length} envelopes, ${idPrefixes.length} id prefixes`); } await main(); diff --git a/src/core/id.ts b/src/core/id.ts index 8d6bfc2..aa6b95f 100644 --- a/src/core/id.ts +++ b/src/core/id.ts @@ -50,9 +50,9 @@ export const ID_PREFIXES = { connection_domain: 'conn_domain', directory: 'directory', directory_user: 'directory_user', - directory_group: 'directory_grp', - event: 'evt', - invitation: 'inv', + directory_group: 'directory_group', + event: 'event', + invitation: 'invitation', session: 'session', email_verification: 'email_verification', password_reset: 'password_reset', @@ -69,26 +69,27 @@ export const ID_PREFIXES = { profile: 'prof', pipe_connection: 'pipe_conn', redirect_uri: 'redir', - cors_origin: 'cors', - authorized_application: 'auth_app', - // Production connected-account ids are data installations (`data_installation_01…`), and - // every account of one provider installs the same environment-level data integration. + cors_origin: 'cors_origin', + authorized_application: 'authorized_connect_app', + // Connected-account ids are data installations (`data_installation_01…`) — the spec's + // `ConnectedAccount.id` example agrees — because every account of one provider installs + // the same environment-level data integration. connected_account: 'data_installation', data_integration: 'data_integration', role: 'role', permission: 'perm', role_permission: 'rp', - authorization_resource: 'auth_res', - role_assignment: 'ra', + authorization_resource: 'authz_resource', + role_assignment: 'role_assignment', audit_log_action: 'audit_action', audit_log_event: 'audit_event', - audit_log_export: 'audit_export', + audit_log_export: 'audit_log_export', feature_flag: 'flag', flag_target: 'flag_target', connect_application: 'connect_app', client_secret: 'client_secret', data_integration_auth: 'di_auth', - radar_attempt: 'radar_attempt', + radar_attempt: 'radar_att', webhook_endpoint: 'we', agent_blueprint: 'agent_blueprint', agent_instance: 'agent', diff --git a/src/workos/generated/response-shapes.ts b/src/workos/generated/response-shapes.ts index 2a5c747..d8e6fc4 100644 --- a/src/workos/generated/response-shapes.ts +++ b/src/workos/generated/response-shapes.ts @@ -8,9 +8,12 @@ * - RESPONSE_SHAPE_REQUIREMENTS per resource (OBJECT_SCHEMA_MAP) * - RESPONSE_ENVELOPE_REQUIREMENTS per operation (ENVELOPE_SCHEMA_MAP) * - * Consumed by src/workos/response-shapes.spec.ts and - * src/workos/response-envelopes.spec.ts to assert the emulator matches the - * spec and never leaks internal fields. + * Plus ID_PREFIX_REQUIREMENTS, discovered structurally from the spec's own + * `id` examples rather than from a curated map. + * + * Consumed by src/workos/response-shapes.spec.ts, + * src/workos/response-envelopes.spec.ts and src/workos/id-prefixes.spec.ts to + * assert the emulator matches the spec and never leaks internal fields. */ export interface ResponseShapeRequirement { @@ -464,3 +467,353 @@ export const RESPONSE_ENVELOPE_REQUIREMENTS: Record = { + agent_blueprint: { + prefix: 'agent_blueprint', + example: 'agent_blueprint_01EHWNCE74X7JSDV0X3SZ3KJNY', + source: 'AgentBlueprint', + conflicts: [], + }, + agent_identity: { + prefix: 'agent_identity', + example: 'agent_identity_01EHWNCE74X7JSDV0X3SZ3KJNY', + source: 'EventSchema', + conflicts: [], + }, + agent_instance: { + prefix: 'agent', + example: 'agent_01EHWNCE74X7JSDV0X3SZ3KJNY', + source: 'AgentInstance', + conflicts: [], + }, + agent_instance_session: { + prefix: 'agent_session', + example: 'agent_session_01EHWNCE74X7JSDV0X3SZ3KJNY', + source: 'AgentInstanceSession', + conflicts: [], + }, + agent_registration: { + prefix: 'agent_reg', + example: 'agent_reg_01EHWNCE74X7JSDV0X3SZ3KJNY', + source: 'EventSchema', + conflicts: [], + }, + agent_registration_claim: { + prefix: 'agent_reg_claim', + example: 'agent_reg_claim_01EHWNCE74X7JSDV0X3SZ3KJNY', + source: 'EventSchema', + conflicts: [], + }, + agent_registration_claim_attempt: { + prefix: 'agent_reg_claim_attempt', + example: 'agent_reg_claim_attempt_01EHWNCE74X7JSDV0X3SZ3KJNY', + source: 'EventSchema', + conflicts: [], + }, + agent_registration_credential: { + prefix: 'agent_reg_credential', + example: 'agent_reg_credential_01EHWNCE74X7JSDV0X3SZ3KJNY', + source: 'EventSchema', + conflicts: [], + }, + api_key: { + prefix: 'api_key', + example: 'api_key_01EHZNVPK3SFK441A1RGBFSHRT', + source: 'ApiKey', + conflicts: [], + }, + audit_log_export: { + prefix: 'audit_log_export', + example: 'audit_log_export_01GBZK5MP7TD1YCFQHFR22180V', + source: 'AuditLogExportJson', + conflicts: [], + }, + authentication_challenge: { + prefix: 'auth_challenge', + example: 'auth_challenge_01FVYZ5QM8N98T9ME5BCB2BBMJ', + source: 'AuthenticationChallenge', + conflicts: [], + }, + authentication_factor: { + prefix: 'auth_factor', + example: 'auth_factor_01FVYZ5QM8N98T9ME5BCB2BBMJ', + source: 'AuthenticationFactor', + conflicts: [], + }, + authorization_resource: { + prefix: 'authz_resource', + example: 'authz_resource_01HXYZ123456789ABCDEFGH', + source: 'AuthorizationResource', + conflicts: [], + }, + authorized_connect_application: { + prefix: 'authorized_connect_app', + example: 'authorized_connect_app_01HXYZ123456789ABCDEFGHIJ', + source: 'AuthorizedConnectApplicationList', + conflicts: [], + }, + connect_application: { + prefix: 'conn_app', + example: 'conn_app_01HXYZ123456789ABCDEFGHIJ', + source: 'ConnectApplication', + conflicts: [], + }, + connect_application_secret: { + prefix: 'secret', + example: 'secret_01J9Q2Z3X4Y5W6V7U8T9S0R1Q', + source: 'NewConnectApplicationSecret', + conflicts: [], + }, + connected_account: { + prefix: 'data_installation', + example: 'data_installation_01EHZNVPK3SFK441A1RGBFSHRT', + source: 'ConnectedAccount', + conflicts: [], + }, + connection: { + prefix: 'conn', + example: 'conn_01E4ZCR3C56J083X43JQXF3JK5', + source: 'Connection', + conflicts: [], + }, + connection_domain: { + prefix: 'org_domain', + example: 'org_domain_01EHZNVPK2QXHMVWCEDQEKY69A', + source: 'Connection', + conflicts: ['conn_domain'], + }, + cors_origin: { + prefix: 'cors_origin', + example: 'cors_origin_01HXYZ123456789ABCDEFGHIJ', + source: 'CorsOriginResponse', + conflicts: [], + }, + data_integration: { + prefix: 'data_integration', + example: 'data_integration_01EHZNVPK3SFK441A1RGBFSHRT', + source: 'DataIntegration', + conflicts: [], + }, + data_integration_configuration: { + prefix: 'data_integration', + example: 'data_integration_01EHZNVPK3SFK441A1RGBFSHRT', + source: 'DataIntegrationConfigurationResponse', + conflicts: [], + }, + data_provider: { + prefix: 'data_integration', + example: 'data_integration_01EHZNVPK3SFK441A1RGBFSHRT', + source: 'DataIntegrationsListResponse', + conflicts: [], + }, + directory: { + prefix: 'directory', + example: 'directory_01ECAZ4NV9QMV47GW873HDCX74', + source: 'Directory', + conflicts: [], + }, + directory_group: { + prefix: 'directory_group', + example: 'directory_group_01E1JJS84MFPPQ3G655FHTKX6Z', + source: 'DirectoryGroup', + conflicts: [], + }, + directory_token: { + prefix: 'directory_token', + example: 'directory_token_01EHWNCE74X7JSDV0X3SZ3KJNY', + source: 'EventSchema', + conflicts: [], + }, + directory_user: { + prefix: 'directory_user', + example: 'directory_user_01E1JG7J09H96KYP8HM9B0G5SJ', + source: 'DirectoryUser', + conflicts: [], + }, + email_verification: { + prefix: 'email_verification', + example: 'email_verification_01E4ZCR3C56J083X43JQXF3JK5', + source: 'EmailVerification', + conflicts: [], + }, + event: { + prefix: 'event', + example: 'event_01EHZNVPK3SFK441A1RGBFSHRT', + source: 'EventSchema', + conflicts: [], + }, + feature_flag: { + prefix: 'flag', + example: 'flag_01EHZNVPK3SFK441A1RGBFSHRT', + source: 'Flag', + conflicts: [], + }, + group: { + prefix: 'group', + example: 'group_01HXYZ123456789ABCDEFGHIJ', + source: 'Group', + conflicts: [], + }, + group_role_assignment: { + prefix: 'gra', + example: 'gra_01HXYZ123456789ABCDEFGH', + source: 'GroupRoleAssignment', + conflicts: [], + }, + invitation: { + prefix: 'invitation', + example: 'invitation_01E4ZCR3C56J083X43JQXF3JK5', + source: 'UserlandUserInvite', + conflicts: [], + }, + it_contact: { + prefix: 'it_contact', + example: 'it_contact_01HXYZ123456789ABCDEFGHIJ', + source: 'ItContact', + conflicts: [], + }, + magic_auth: { + prefix: 'magic_auth', + example: 'magic_auth_01HWZBQZY2M3AMQW166Q22K88F', + source: 'MagicAuth', + conflicts: [], + }, + organization: { + prefix: 'org', + example: 'org_01EHWNCE74X7JSDV0X3SZ3KJNY', + source: 'Organization', + conflicts: [], + }, + organization_domain: { + prefix: 'org_domain', + example: 'org_domain_01EHZNVPK2QXHMVWCEDQEKY69A', + source: 'OrganizationDomainStandAlone', + conflicts: [], + }, + organization_membership: { + prefix: 'om', + example: 'om_01HXYZ123456789ABCDEFGHIJ', + source: 'UserlandUserOrganizationMembership', + conflicts: [], + }, + password_reset: { + prefix: 'password_reset', + example: 'password_reset_01E4ZCR3C56J083X43JQXF3JK5', + source: 'PasswordReset', + conflicts: [], + }, + permission: { + prefix: 'perm', + example: 'perm_01HXYZ123456789ABCDEFGHIJ', + source: 'AuthorizationPermission', + conflicts: [], + }, + profile: { + prefix: 'prof', + example: 'prof_01DMC79VCBZ0NY2099737PSVF1', + source: 'Profile', + conflicts: [], + }, + radar_challenge: { + prefix: 'radar_challenge', + example: 'radar_challenge_01HWZBQZY2M3AMQW166Q22K88F', + source: 'RadarChallenge', + conflicts: [], + }, + redirect_uri: { + prefix: 'redir', + example: 'redir_01EHZNVPK3SFK441A1RGBFSHRT', + source: 'RedirectUri', + conflicts: [], + }, + role: { + prefix: 'role', + example: 'role_01EHQMYV6MBK39QC5PZXHY59C3', + source: 'Role', + conflicts: [], + }, + role_assignment: { + prefix: 'role_assignment', + example: 'role_assignment_01HXYZ123456789ABCDEFGH', + source: 'UserRoleAssignment', + conflicts: [], + }, + saml_idp_signing_certificate: { + prefix: 'saml_x509_cert', + example: 'saml_x509_cert_01E4ZCR3C56J083X43JQXF3JK5', + source: 'SamlIdpSigningCertificate', + conflicts: [], + }, + saml_sp_encryption_certificate: { + prefix: 'saml_enc_key_pair', + example: 'saml_enc_key_pair_01E4ZCR3C56J083X43JQXF3JK5', + source: 'SamlSpEncryptionCertificate', + conflicts: [], + }, + saml_sp_signing_certificate: { + prefix: 'saml_party_trust', + example: 'saml_party_trust_01E4ZCR3C56J083X43JQXF3JK5', + source: 'SamlSpSigningCertificate', + conflicts: [], + }, + session: { + prefix: 'session', + example: 'session_01H93ZY4F80QPBEZ1R5B2SHQG8', + source: 'EventSchema', + conflicts: [], + }, + team: { + prefix: 'team', + example: 'team_01JX9AN6E02HAG2Q2CKGC1XT5W', + source: 'Team', + conflicts: [], + }, + user: { + prefix: 'user', + example: 'user_01E4ZCR3C56J083X43JQXF3JK5', + source: 'UserlandUser', + conflicts: [], + }, + waitlist: { + prefix: 'waitlist', + example: 'waitlist_01E4ZCR3C56J083X43JQXF3JK5', + source: 'Waitlist', + conflicts: [], + }, + waitlist_entry: { + prefix: 'wl_user', + example: 'wl_user_01E4ZCR3C56J083X43JQXF3JK5', + source: 'WaitlistEntry', + conflicts: [], + }, + waitlist_user: { + prefix: 'wl_user', + example: 'wl_user_01E4ZCR3C56J083X43JQXF3JK5', + source: 'WaitlistUser', + conflicts: [], + }, + webhook_endpoint: { + prefix: 'we', + example: 'we_0123456789', + source: 'WebhookEndpointJson', + conflicts: [], + }, +}; diff --git a/src/workos/id-prefixes.spec.ts b/src/workos/id-prefixes.spec.ts new file mode 100644 index 0000000..fdcf44b --- /dev/null +++ b/src/workos/id-prefixes.spec.ts @@ -0,0 +1,118 @@ +/** + * ID prefix conformance: asserts the prefixes the emulator mints ids with match the + * prefixes the OpenAPI spec's own `id` examples use. The spec requirements come from + * src/workos/generated/response-shapes.ts (regenerate with `npm run gen:shapes`), where + * they are discovered structurally — every object the spec gives a prefixed `id` example + * for is catalogued, nothing is curated. + * + * Response shape conformance cannot catch this: a field set says nothing about what goes + * in the fields, so `id: "ra_01…"` satisfies a spec that documents `role_assignment_01…`. + * Prefixes are load-bearing for consumers — SDK fixtures, routing by id, and anything + * that string-matches an id — so a wrong one is a contract break that looks like nothing. + * + * Divergences live in the ledgers below, as exact sets with a reason each. Closing one — + * the spec grows an example, or a prefix is brought into line — fails until its ledger + * entry is deleted, and a new divergence fails outright, so drift can't accrue silently. + */ +import { describe, it, expect } from 'bun:test'; +import { ID_PREFIXES } from '../core/index.js'; +import { ID_PREFIX_REQUIREMENTS } from './generated/response-shapes.js'; + +/** + * ID_PREFIXES keys the emulator spells differently from the spec's `object` + * discriminator. Only the name differs; the prefix is still required to match. + */ +const OBJECT_TYPE_ALIASES: Record = { + authorized_application: 'authorized_connect_application', + client_secret: 'connect_application_secret', +}; + +/** + * Objects the emulator mints ids for that the spec gives no object-level `id` example + * for — mostly records that exist only inside the emulator, or spec resources the spec + * itself never shows an id for. Each prefix here is chosen by hand, so each says why. + */ +const NO_SPEC_ID_EXAMPLE: Record = { + audit_log_action: 'AuditLogActionJson carries no `id` — actions are identified by `name`.', + audit_log_event: 'Ingestion (AuditLogEventDto) is write-only; no id-bearing event object is documented.', + authorization_code: 'OAuth codes are opaque strings in the spec (`code`), not a resource; the emulator stores one.', + data_integration_auth: 'Emulator-internal: the OAuth handoff behind a data integration install.', + device_authorization: 'The device grant is addressed by `device_code`/`user_code`; the record is emulator-internal.', + external_auth_session: + 'Not an object in the spec, but its `external_auth_id` example is `ext_auth_…`, which this matches.', + flag_target: 'Targets are inline on the flag (Flag.targets); there is no standalone target resource.', + group_membership: 'Membership appears only as CreateGroupMembershipDto — no resource, so no id.', + identity: '/user_management/users/{id}/identities returns objects keyed by `idp_id`, with no `id` at all.', + pipe_connection: 'Emulator-internal: the Pipes connection record behind a connected account.', + radar_attempt: + 'No object-level example, but `/radar/attempts/{id}` documents `radar_att_01HZBC6N1EB1ZY7KG32X` and ' + + 'RadarStandaloneResponse.attempt_id repeats it — so the prefix is pinned to that.', + refresh_token: 'Refresh tokens are opaque strings in the spec; the emulator stores a record behind one.', + role_permission: 'The role↔permission join is emulator-internal; the spec exposes permissions on the role.', + sso_authorization: 'Emulator-internal: the authorize → callback handoff for SSO.', +}; + +/** + * Prefixes that knowingly differ from the spec's example, and why. These are the entries + * to delete — not extend — when the divergence is closed. + */ +const TRACKED_DIVERGENCES: Record = { + connection_domain: + 'The spec contradicts itself: Connection.domains[] examples an `org_domain_…` id, while the ' + + 'connection.* event payloads example `conn_domain_…` for the same object. The emulator follows the ' + + 'events, which is what production emits.', + connect_application: 'Spec examples `conn_app_…`; being corrected separately — delete this entry with that change.', + client_secret: 'Spec examples `secret_…`; being corrected separately — delete this entry with that change.', +}; + +const prefixes: Record = { ...ID_PREFIXES }; +const specObjectType = (key: string): string => OBJECT_TYPE_ALIASES[key] ?? key; + +describe('ID prefix conformance', () => { + it('mints the prefix the spec documents for every object the spec gives an example for', () => { + const actual: Record = {}; + const expected: Record = {}; + for (const [key, prefix] of Object.entries(prefixes)) { + const requirement = ID_PREFIX_REQUIREMENTS[specObjectType(key)]; + if (!requirement || key in TRACKED_DIVERGENCES) continue; + actual[key] = prefix; + expected[key] = requirement.prefix; + } + expect(actual).toEqual(expected); + }); + + it('accounts for every object it mints ids for — matched against the spec, or ledgered', () => { + const unaccounted = Object.keys(prefixes).filter( + (key) => !ID_PREFIX_REQUIREMENTS[specObjectType(key)] && !(key in NO_SPEC_ID_EXAMPLE), + ); + expect(unaccounted).toEqual([]); + }); + + it('has no stale no-example ledger entry — the spec grew an example, so the entry must go', () => { + const covered = Object.keys(NO_SPEC_ID_EXAMPLE).filter((key) => ID_PREFIX_REQUIREMENTS[specObjectType(key)]); + expect(covered).toEqual([]); + }); + + it('has no stale divergence — a tracked divergence that now matches the spec must be deleted', () => { + const closed = Object.keys(TRACKED_DIVERGENCES).filter((key) => { + const requirement = ID_PREFIX_REQUIREMENTS[specObjectType(key)]; + return requirement !== undefined && requirement.prefix === prefixes[key]; + }); + expect(closed).toEqual([]); + }); + + it('ledgers only objects the emulator actually mints ids for', () => { + const unknown = [...Object.keys(NO_SPEC_ID_EXAMPLE), ...Object.keys(TRACKED_DIVERGENCES)].filter( + (key) => !(key in prefixes), + ); + expect(unknown).toEqual([]); + }); + + it('extracted a prefix for the objects whose ids customers read back', () => { + // A guard on the guard: if the extractor silently stopped finding examples, every + // assertion above would pass vacuously. + for (const objectType of ['user', 'organization', 'event', 'invitation', 'role_assignment']) { + expect(ID_PREFIX_REQUIREMENTS[objectType]?.prefix).toBeString(); + } + }); +}); diff --git a/src/workos/response-shapes.spec.ts b/src/workos/response-shapes.spec.ts index 6926d84..e356257 100644 --- a/src/workos/response-shapes.spec.ts +++ b/src/workos/response-shapes.spec.ts @@ -113,7 +113,7 @@ const directory: WorkOSDirectory = { }; const directoryGroup: WorkOSDirectoryGroup = { - id: 'directory_grp_01', + id: 'directory_group_01', object: 'directory_group', directory_id: 'directory_01', organization_id: 'org_01', diff --git a/src/workos/routes/audit-logs.spec.ts b/src/workos/routes/audit-logs.spec.ts index 9d8542e..9dd6b58 100644 --- a/src/workos/routes/audit-logs.spec.ts +++ b/src/workos/routes/audit-logs.spec.ts @@ -93,7 +93,7 @@ describe('Audit Logs routes', () => { }); it('returns 404 for nonexistent export', async () => { - const res = await req('/audit_logs/exports/audit_export_nonexistent'); + const res = await req('/audit_logs/exports/audit_log_export_nonexistent'); expect(res.status).toBe(404); }); diff --git a/src/workos/routes/authorization-checks.spec.ts b/src/workos/routes/authorization-checks.spec.ts index 55800ef..8f152e0 100644 --- a/src/workos/routes/authorization-checks.spec.ts +++ b/src/workos/routes/authorization-checks.spec.ts @@ -716,7 +716,7 @@ describe('Authorization check + role assignment routes', () => { const { membership } = await setupWithResource(); const res = await req(`/authorization/organization_memberships/${membership.id}/check`, { method: 'POST', - body: JSON.stringify({ permission_slug: 'posts:read', resource_id: 'auth_res_nonexistent' }), + body: JSON.stringify({ permission_slug: 'posts:read', resource_id: 'authz_resource_nonexistent' }), }); expect(res.status).toBe(404); }); diff --git a/src/workos/routes/authorization-resources.spec.ts b/src/workos/routes/authorization-resources.spec.ts index 06cdb69..87b2584 100644 --- a/src/workos/routes/authorization-resources.spec.ts +++ b/src/workos/routes/authorization-resources.spec.ts @@ -44,7 +44,7 @@ describe('Authorization resource routes', () => { expect(resource.resource_type_slug).toBe('document'); expect(resource.external_id).toBe('doc-123'); expect(resource.organization_id).toBe(org.id); - expect(resource.id).toMatch(/^auth_res_/); + expect(resource.id).toMatch(/^authz_resource_/); }); it('rejects missing required fields', async () => { @@ -327,7 +327,7 @@ describe('Authorization resource routes', () => { external_id: 'proj-3', organization_id: org.id, name: 'proj-3', - parent_resource_id: 'auth_res_nonexistent', + parent_resource_id: 'authz_resource_nonexistent', }), }); expect(unknownParent.status).toBe(404); diff --git a/src/workos/routes/config.spec.ts b/src/workos/routes/config.spec.ts index 2b86c35..0d97bcd 100644 --- a/src/workos/routes/config.spec.ts +++ b/src/workos/routes/config.spec.ts @@ -56,7 +56,7 @@ describe('Config routes', () => { const data = await json(res); expect(data.object).toBe('cors_origin'); expect(data.origin).toBe('http://localhost:3000'); - expect(data.id).toMatch(/^cors_/); + expect(data.id).toMatch(/^cors_origin_/); }); it('rejects duplicate CORS origin', async () => { diff --git a/src/workos/routes/invitations.spec.ts b/src/workos/routes/invitations.spec.ts index a71eb47..5016f60 100644 --- a/src/workos/routes/invitations.spec.ts +++ b/src/workos/routes/invitations.spec.ts @@ -31,7 +31,7 @@ describe('Invitation routes', () => { expect(inv.state).toBe('pending'); expect(inv.token).toBeDefined(); expect(inv.accept_invitation_url).toContain(inv.token); - expect(inv.id).toMatch(/^inv_/); + expect(inv.id).toMatch(/^invitation_/); // The generated SDK reads these as required keys; omitting them raises KeyError on parse. expect(inv.accepted_at).toBeNull(); expect(inv.revoked_at).toBeNull(); diff --git a/src/workos/routes/radar.spec.ts b/src/workos/routes/radar.spec.ts index 90d6cb0..337d947 100644 --- a/src/workos/routes/radar.spec.ts +++ b/src/workos/routes/radar.spec.ts @@ -61,7 +61,7 @@ describe('Radar routes', () => { }); it('returns 404 for nonexistent attempt', async () => { - const res = await req('/radar/attempts/radar_attempt_nonexistent'); + const res = await req('/radar/attempts/radar_att_nonexistent'); expect(res.status).toBe(404); }); From 120378672618d21e50c06ef9db4a8d8ece81767e Mon Sep 17 00:00:00 2001 From: Daniel Loader Date: Tue, 22 Sep 2026 09:53:53 +0100 Subject: [PATCH 2/4] fix(codegen): emit spec-derived catalog keys as string literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The id-prefix catalog is discovered structurally from the spec rather than from a curated map, so its keys and values are whatever upstream ships. They were written straight into the generated TypeScript: an object discriminator containing a hyphen would have produced an unparseable property name, and a schema name containing a quote would have ended the string literal early — either way leaving a generator that cannot regenerate. Keys and spec-derived strings now go through JSON.stringify. oxfmt still normalizes the redundant quoting afterwards, so the generated file is byte-identical. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/gen-shapes-lib.spec.ts | 39 +++++++++++++++++++++++++++++++--- scripts/gen-shapes-lib.ts | 14 +++++++----- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/scripts/gen-shapes-lib.spec.ts b/scripts/gen-shapes-lib.spec.ts index 11d21cd..1721f58 100644 --- a/scripts/gen-shapes-lib.spec.ts +++ b/scripts/gen-shapes-lib.spec.ts @@ -330,10 +330,43 @@ describe('generateShapesFile', () => { expect(out).toContain("schema: 'WidgetValidation'"); }); + // Spec-derived text is emitted as JSON string literals, so this catalog's raw output is + // double-quoted where the curated ones above are not; gen-shapes.ts runs oxfmt over the + // file afterwards, which normalizes the quoting that does not need to be there. it('emits an ID_PREFIX_REQUIREMENTS record keyed by object type', () => { expect(out).toContain('export const ID_PREFIX_REQUIREMENTS'); - expect(out).toContain("prefix: 'wg'"); - expect(out).toContain("example: 'wg_01HXYZ123'"); - expect(out).toContain("conflicts: ['widget']"); + expect(out).toContain('"widget": {'); + expect(out).toContain('prefix: "wg"'); + expect(out).toContain('example: "wg_01HXYZ123"'); + expect(out).toContain('conflicts: ["widget"]'); + }); + + // The id-prefix catalog is discovered from the spec rather than curated, so a discriminator + // or schema name the spec invents has to survive being written into TypeScript. Unquoted, a + // hyphen produces an unparseable key and an apostrophe ends the literal early — either way + // `gen:shapes` emits a file it can no longer regenerate from. + it('quotes and escapes spec-derived keys and values that are not safe identifiers', () => { + const hostile = generateShapesFile( + [], + [], + [ + { + objectType: 'odd-object.type', + prefix: "o'dd", + example: "o'dd_01HXYZ123", + source: 'Schema\\With\\Escapes', + conflicts: ["c'onflict"], + }, + ], + ); + + const body = hostile.slice(hostile.indexOf('export const ID_PREFIX_REQUIREMENTS')); + const literal = body.slice(body.indexOf('{'), body.indexOf('\n};') + 2); + // The proof that matters: spec-derived text reaches TypeScript that still parses. + expect(() => new Function(`return (${literal})`)).not.toThrow(); + + const parsed = new Function(`return (${literal})`)() as Record>; + expect(parsed['odd-object.type'].prefix).toBe("o'dd"); + expect(parsed['odd-object.type'].source).toBe('Schema\\With\\Escapes'); }); }); diff --git a/scripts/gen-shapes-lib.ts b/scripts/gen-shapes-lib.ts index d2cef60..889abdf 100644 --- a/scripts/gen-shapes-lib.ts +++ b/scripts/gen-shapes-lib.ts @@ -599,12 +599,16 @@ export function generateShapesFile( lines.push(' * ID_PREFIXES and ledgers what is left over.'); lines.push(' */'); lines.push('export const ID_PREFIX_REQUIREMENTS: Record = {'); + // Every key and value here comes from the spec rather than a curated list, so each is + // emitted as a JSON string literal: a discriminator or schema name carrying a hyphen or a + // quote would otherwise produce TypeScript that does not parse, and the failure would be a + // generator that cannot regenerate. oxfmt drops the redundant quoting on the way out. for (const entry of idPrefixes) { - const conflicts = entry.conflicts.map((c) => `'${c}'`).join(', '); - lines.push(` ${entry.objectType}: {`); - lines.push(` prefix: '${entry.prefix}',`); - lines.push(` example: '${entry.example}',`); - lines.push(` source: '${entry.source}',`); + const conflicts = entry.conflicts.map((c) => JSON.stringify(c)).join(', '); + lines.push(` ${JSON.stringify(entry.objectType)}: {`); + lines.push(` prefix: ${JSON.stringify(entry.prefix)},`); + lines.push(` example: ${JSON.stringify(entry.example)},`); + lines.push(` source: ${JSON.stringify(entry.source)},`); lines.push(` conflicts: [${conflicts}],`); lines.push(' },'); } From 802b0a8130b09edf891cc019e5289b9288434522 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 22 Sep 2026 13:39:41 -0400 Subject: [PATCH 3/4] test(radar): pin the radar_att_ prefix the conformance suite skips Radar has no object-level example in the spec, so the prefix suite excludes it; reverting `radar_attempt` to its old prefix left every test green. --- src/workos/routes/radar.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/workos/routes/radar.spec.ts b/src/workos/routes/radar.spec.ts index 337d947..92f79b9 100644 --- a/src/workos/routes/radar.spec.ts +++ b/src/workos/routes/radar.spec.ts @@ -56,6 +56,9 @@ describe('Radar routes', () => { const res = await req(`/radar/attempts/${attempt.id}`); expect(res.status).toBe(200); const data = await json(res); + // The spec has no object-level example for the prefix conformance suite to check; the + // `/radar/attempts/{id}` path documents `radar_att_…`, so this pins it. + expect(data.id).toMatch(/^radar_att_[0-9A-HJKMNP-TV-Z]{26}$/); expect(data.ip_address).toBe('5.6.7.8'); expect(data.signals).toHaveLength(1); }); From 611c92e9357b7dc5ebf0783b7d33380eed40e5f2 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Tue, 22 Sep 2026 15:21:07 -0400 Subject: [PATCH 4/4] test(ids): close the Connect divergences #121 corrected `connect_application` and `client_secret` now mint `conn_app_` and `secret_` on main, so the ledger entries tracking them as known divergences are stale and the guard that exists for exactly this case fails. --- src/workos/id-prefixes.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/workos/id-prefixes.spec.ts b/src/workos/id-prefixes.spec.ts index fdcf44b..a2ab8ac 100644 --- a/src/workos/id-prefixes.spec.ts +++ b/src/workos/id-prefixes.spec.ts @@ -61,8 +61,6 @@ const TRACKED_DIVERGENCES: Record = { 'The spec contradicts itself: Connection.domains[] examples an `org_domain_…` id, while the ' + 'connection.* event payloads example `conn_domain_…` for the same object. The emulator follows the ' + 'events, which is what production emits.', - connect_application: 'Spec examples `conn_app_…`; being corrected separately — delete this entry with that change.', - client_secret: 'Spec examples `secret_…`; being corrected separately — delete this entry with that change.', }; const prefixes: Record = { ...ID_PREFIXES };