From 0f9f860217614997075ea3444cd6efed3884bc4d Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:16:12 -0700 Subject: [PATCH 1/2] fix(server): omit relations to sliced-away models from OpenAPI spec The RPC OpenAPI generator emitted a `$ref` for every relation field regardless of slicing. A model referencing an excluded model (e.g. `Link.workspace` -> excluded `Workspace`) produced a dangling `$ref: #/components/schemas/Workspace`, leaking the excluded model and making the generated spec structurally invalid. `buildModelEntitySchema` now skips relation fields whose target model is not included by the slicing options. The REST generator already guarded these spots. Fixes #2628 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/server/src/api/rpc/openapi.ts | 5 +++++ .../server/test/openapi/rpc-openapi.test.ts | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/server/src/api/rpc/openapi.ts b/packages/server/src/api/rpc/openapi.ts index 6bd6e2c5c..6c6a0a06c 100644 --- a/packages/server/src/api/rpc/openapi.ts +++ b/packages/server/src/api/rpc/openapi.ts @@ -9,6 +9,7 @@ import { DEFAULT_SPEC_VERSION, getIncludedModels, getMetaDescription, + isModelIncluded, isOperationIncluded, isProcedureIncluded, mayDenyAccess, @@ -685,6 +686,10 @@ export class RPCApiSpecGenerator { if (fieldDef.omit) continue; if (fieldDef.relation) { + // Skip relations pointing to a model that's sliced away — otherwise we'd emit + // a dangling `$ref` to a schema that's not in the spec. + if (!isModelIncluded(fieldDef.type, this.queryOptions)) continue; + // Relation fields appear only with `include` — mark as optional. // To-one optional relations are nullable (the ORM returns null when not found). const refSchema: ReferenceObject = { $ref: `#/components/schemas/${fieldDef.type}` }; diff --git a/packages/server/test/openapi/rpc-openapi.test.ts b/packages/server/test/openapi/rpc-openapi.test.ts index 64d4b1ca4..24b728388 100644 --- a/packages/server/test/openapi/rpc-openapi.test.ts +++ b/packages/server/test/openapi/rpc-openapi.test.ts @@ -596,6 +596,26 @@ describe('RPC OpenAPI spec generation - queryOptions slicing', () => { expect(spec.components?.schemas?.['User']).toBeDefined(); }); + it('drops relation fields pointing to excluded models from entity schemas', async () => { + const client = await createTestClient(schema); + const handler = new RPCApiHandler({ + schema: client.$schema, + queryOptions: { slicing: { excludedModels: ['Post'] as any } }, + }); + // `generateSpec` also validates the document, so a dangling `$ref` to the + // excluded `Post` schema would fail here. + const spec = await generateSpec(handler); + + // referencing models keep their other fields but lose relations to the excluded model + const userProps = (spec.components?.schemas?.['User'] as any)?.properties; + expect(userProps?.['email']).toBeDefined(); + expect(userProps?.['posts']).toBeUndefined(); + + const commentProps = (spec.components?.schemas?.['Comment'] as any)?.properties; + expect(commentProps?.['content']).toBeDefined(); + expect(commentProps?.['post']).toBeUndefined(); + }); + it('includedModels removes excluded model entity schemas from components', async () => { const client = await createTestClient(schema); const handler = new RPCApiHandler({ From 0d393a46dd2a1b15e85a84d31246aed86998d3c8 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:20:58 -0700 Subject: [PATCH 2/2] fix(server): avoid dangling ref for procedures returning sliced models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A procedure is exposed based only on procedure-level slicing, so a procedure whose return type is a model that's excluded via model slicing emitted a `$ref` to the absent model entity schema — another dangling reference that leaks the excluded model and invalidates the spec. `getProcedureDataSchema` now falls back to a generic schema when the return model is sliced away. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/server/src/api/rpc/openapi.ts | 4 ++++ .../server/test/openapi/rpc-openapi.test.ts | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/server/src/api/rpc/openapi.ts b/packages/server/src/api/rpc/openapi.ts index 6c6a0a06c..35ff95649 100644 --- a/packages/server/src/api/rpc/openapi.ts +++ b/packages/server/src/api/rpc/openapi.ts @@ -486,6 +486,10 @@ export class RPCApiSpecGenerator { if (this.isBuiltinType(returnType)) { base = this.builtinTypeToJsonSchema(returnType as BuiltinType); + } else if (this.schema.models?.[returnType] && !isModelIncluded(returnType, this.queryOptions)) { + // Return type is a model that's sliced away — its entity schema isn't emitted, + // so reference it with a generic schema instead of a dangling `$ref`. + base = {}; } else if ( this.schema.enums?.[returnType] || this.schema.models?.[returnType] || diff --git a/packages/server/test/openapi/rpc-openapi.test.ts b/packages/server/test/openapi/rpc-openapi.test.ts index 24b728388..6e25cb270 100644 --- a/packages/server/test/openapi/rpc-openapi.test.ts +++ b/packages/server/test/openapi/rpc-openapi.test.ts @@ -878,6 +878,30 @@ mutation procedure softDelete(id: Int?): User expect(schemaKeys.some((k) => k.startsWith('createUser'))).toBe(true); }); + it('procedure returning an excluded model does not emit a dangling ref', async () => { + const client = await createTestClient(procSchema); + const handler = new RPCApiHandler({ + schema: client.$schema, + queryOptions: { slicing: { excludedModels: ['User'] as any } }, + }); + // `getUser`/`createUser`/`optionalSearch` all return `User`, which is excluded. + // `generateSpec` validates, so a dangling `$ref` to the absent `User` schema would fail. + const spec = await generateSpec(handler); + + expect(spec.components?.schemas?.['User']).toBeUndefined(); + + // the procedures themselves are still exposed, but their result shape is generic + const dataSchema = (spec.paths?.['/$procs/getUser']?.get as any)?.responses?.['200']?.content?.[ + 'application/json' + ]?.schema?.properties?.data; + expect(dataSchema).toEqual({}); + + const listDataSchema = (spec.paths?.['/$procs/optionalSearch']?.get as any)?.responses?.['200']?.content?.[ + 'application/json' + ]?.schema?.properties?.data; + expect(listDataSchema).toEqual({ type: 'array', items: {} }); + }); + it('slicing includedProcedures removes non-listed procedure args from components schemas', async () => { const client = await createTestClient(procSchema); const handler = new RPCApiHandler({