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..1721f58 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,44 @@ describe('generateShapesFile', () => { expect(out).toContain("'POST /widgets/validations': {"); 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('"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 350bdf9..31fef0b 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 @@ -374,7 +378,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.'); @@ -386,9 +553,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 {'); @@ -429,5 +599,38 @@ 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 = {'); + // 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) => 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(' },'); + } + 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 67940c3..39c357d 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: 'conn_app', client_secret: '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 c6f6aba..16fca09 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 { @@ -479,3 +482,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..a2ab8ac --- /dev/null +++ b/src/workos/id-prefixes.spec.ts @@ -0,0 +1,116 @@ +/** + * 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.', +}; + +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 071e5cb..02aa2cf 100644 --- a/src/workos/response-shapes.spec.ts +++ b/src/workos/response-shapes.spec.ts @@ -115,7 +115,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..92f79b9 100644 --- a/src/workos/routes/radar.spec.ts +++ b/src/workos/routes/radar.spec.ts @@ -56,12 +56,15 @@ 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); }); 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); });