From f799101f9a314d585587686d054b430daa91851a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 23:59:53 +0000 Subject: [PATCH 1/2] feat(spec): declare previewDrafts/state draft-visibility switches on meta-read requests; record environmentId as transport-level (#9741) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016D9wdJR14KKCxz1WgdAzcw --- packages/rest/src/rest-server.ts | 73 ++++++++++++-- packages/spec/authorable-surface/api.json | 3 + packages/spec/src/api/protocol.test.ts | 115 ++++++++++++++++++++++ packages/spec/src/api/protocol.zod.ts | 40 ++++++++ 4 files changed, 222 insertions(+), 9 deletions(-) diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d4f26f2add..7247472662 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -60,6 +60,15 @@ import { refuseUnknownQueryParams } from './query-allowlist.js'; import type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js'; import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api'; import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api'; +// [#9741] Declared request shapes for the meta-read doors below — imported so +// each door's request literal is compiled against the spec contract instead of +// being smuggled past it with `as any` (see `TransportScopedMetaRequest`). +import type { + GetMetaItemsRequest, + GetMetaItemRequest, + GetMetaItemCachedRequest, + GetMetaItemLayeredRequest, +} from '@objectstack/spec/api'; // [#8073] The closed ADR-0112 error vocabulary, so the explain family's single // refusal emitter types its `code` parameter as the vocabulary rather than as // `string` — an invented code is a compile error at the call site instead of a @@ -108,6 +117,25 @@ import { sendError as sendEnvelopeError } from '@objectstack/types'; * widen this contract. */ export type RestProtocol = DataProtocol & MetadataProtocol; + +/** + * [#9741] Typed TRANSPORT envelope for the meta-read doors. + * + * `environmentId` is the multi-kernel routing key, and it is OUT of the + * protocol request shape **by explicit maintainer decision** (ruling recorded + * 2026-08-18 on #9741): `resolveProtocol(environmentId)` selects the target + * kernel *before* the protocol call, and the implementation's parameter types + * (`@objectstack/metadata-protocol`) never read it off the request — the spec + * schemas (`protocol.zod.ts`) record the same exclusion schema-side. The doors + * here still spread it into the outgoing payload (long-standing wire shape, + * deliberately unchanged by the ruling), so this alias declares that one + * transport-level member on top of the declared request type. The point is + * what it makes the compiler do: every OTHER key in a door's request literal + * is now checked against the spec contract — an undeclared member is a compile + * error at the call site, not a cast-and-hope. Never add protocol members + * here; a key that belongs to the request belongs in the spec schema. + */ +type TransportScopedMetaRequest = R & { environmentId?: string }; import { buildFieldMetaMap, referenceFieldNames, @@ -2566,13 +2594,19 @@ export class RestServer { const layeredOrganizationId = organizationIdForMetaRead( req.params.type, layeredCtx?.tenantId, ); - const layered = await p.getMetaItemLayered({ + // [#9741] This door never carried an `as any`, but `p: any` meant its + // request literal was never checked either — the same blind spot with + // a different spelling. Typing the literal (spec shape + the + // transport-level `environmentId`, see `TransportScopedMetaRequest`) + // makes an undeclared key a compile error here too. + const layeredRequest: TransportScopedMetaRequest = { type: req.params.type, name: req.params.name, ...(layeredPackageId ? { packageId: layeredPackageId } : {}), ...(environmentId ? { environmentId } : {}), ...(layeredOrganizationId ? { organizationId: layeredOrganizationId } : {}), - }); + }; + const layered = await p.getMetaItemLayered(layeredRequest); // [ADR-0106 D5(4)] The layered view is a schema-bearing exit — // `code`, `overlay` and `effective` are each a full object schema. // Both entry points (the canonical `/layers` path and the deprecated @@ -3974,13 +4008,18 @@ export class RestServer { const listOrganizationId = organizationIdForMetaRead( req.params.type, listCtx?.tenantId, ); - const items = await p.getMetaItems({ + // [#9741] Typed against the spec request shape plus the + // transport-level `environmentId` — the `as any` this + // literal used to carry is retired now that the spec + // declares `previewDrafts` (and `organizationId`, #9726). + const listRequest: TransportScopedMetaRequest = { type: req.params.type, packageId, ...(previewDrafts ? { previewDrafts: true } : {}), ...(environmentId ? { environmentId } : {}), ...(listOrganizationId ? { organizationId: listOrganizationId } : {}), - } as any); + }; + const items = await p.getMetaItems(listRequest); // RBAC-filter app metadata for authenticated users so // privileged apps (Studio, Setup, etc.) and gated nav @@ -4826,7 +4865,14 @@ export class RestServer { const cacheI18n = await this.resolveI18nService(environmentId, req); const cacheLocale = this.extractLocale(req, cacheI18n); - const result = await p.getMetaItemCached({ + // [#9741] Typed request — `as any` retired. The + // cached read carries NO draft-visibility members + // on purpose: this branch is unreachable when + // `previewDrafts` / `?state=draft` are set (the + // fork above bypasses the cache for both), and the + // implementation's `getMetaItemCached` signature + // declares neither. + const cachedRequest: TransportScopedMetaRequest = { type: req.params.type, name: req.params.name, cacheRequest, @@ -4840,7 +4886,8 @@ export class RestServer { // enters the ETag there, so the validator states // the scope rather than inheriting it. ...(readOrganizationId ? { organizationId: readOrganizationId } : {}), - } as any); + }; + const result = await p.getMetaItemCached(cachedRequest); if (result.notModified) { res.status(304).send(); @@ -4944,18 +4991,26 @@ export class RestServer { const stateParam = typeof req.query?.state === 'string' ? req.query.state.toLowerCase() : undefined; - const envelope = await p.getMetaItem({ + // [#9741] Typed against the spec request shape — + // the `as any` this literal used to carry is + // retired now that the spec declares `state` and + // `previewDrafts` (and `organizationId`, #9726). + // No transport envelope: this door does not thread + // `environmentId` (the kernel was already resolved + // above), so the plain declared shape suffices. + const itemRequest: GetMetaItemRequest = { type: req.params.type, name: req.params.name, packageId, - ...(stateParam === 'draft' ? { state: 'draft' } : {}), + ...(stateParam === 'draft' ? { state: 'draft' as const } : {}), ...(previewDrafts ? { previewDrafts: true } : {}), // [#9454] The uncached arm — `dashboard`'s route // (`isDashboardType`), and every read the cache // exclusions divert here. Same hoisted scope as // the cached arm above, by construction. ...(readOrganizationId ? { organizationId: readOrganizationId } : {}), - } as any) as Record; + }; + const envelope = await p.getMetaItem(itemRequest) as Record; // [#5563] `getMetaItem` answers the envelope // `{ type, name, item, lock, … }`. Unwrap ONCE here; diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index a3217c9514..0893c4ab96 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -739,6 +739,8 @@ "api/GetMetaItemRequest:name", "api/GetMetaItemRequest:organizationId", "api/GetMetaItemRequest:packageId", + "api/GetMetaItemRequest:previewDrafts", + "api/GetMetaItemRequest:state", "api/GetMetaItemRequest:type", "api/GetMetaItemResponse:deletable", "api/GetMetaItemResponse:editable", @@ -755,6 +757,7 @@ "api/GetMetaItemResponse:type", "api/GetMetaItemsRequest:organizationId", "api/GetMetaItemsRequest:packageId", + "api/GetMetaItemsRequest:previewDrafts", "api/GetMetaItemsRequest:type", "api/GetMetaItemsResponse:items", "api/GetMetaItemsResponse:type", diff --git a/packages/spec/src/api/protocol.test.ts b/packages/spec/src/api/protocol.test.ts index 2f317506c4..4f89a5f4be 100644 --- a/packages/spec/src/api/protocol.test.ts +++ b/packages/spec/src/api/protocol.test.ts @@ -1357,6 +1357,121 @@ describe('meta-read request schemas declare organizationId (#9726 — declared = }); }); +describe('meta-read request schemas declare the draft-visibility switches (#9741 — declared = enforced)', () => { + // Maintainer ruling 2026-08-18 (#9741): declare `previewDrafts` / `state` + // exactly where the implementation enforces them, and record `environmentId` + // as transport-level — OUT of the request shape by decision. The + // implementation's inline parameter types are the measure: + // getMetaItems: { …, previewDrafts?: boolean } — no `state` + // getMetaItem: { …, state?: 'active'|'draft', previewDrafts?: boolean } + // getMetaItemCached / getMetaItemLayered: NEITHER member + // As in the #9726 block above, accept-pins assert the parsed VALUE: these are + // non-strict objects, so `success` alone is exactly the silent-strip state + // this card closes. + const previewCases = [ + ['GetMetaItemsRequestSchema', GetMetaItemsRequestSchema, { type: 'object' }], + ['GetMetaItemRequestSchema', GetMetaItemRequestSchema, { type: 'view', name: 'account_list' }], + ] as const; + + it.each(previewCases)('%s accepts previewDrafts and PRESERVES it through parse', (_n, schema, base) => { + const result = schema.safeParse({ ...base, previewDrafts: true }); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data as { previewDrafts?: boolean }).previewDrafts).toBe(true); + } + }); + + it.each(previewCases)('%s keeps previewDrafts OPTIONAL — a published-world read stays valid', (_n, schema, base) => { + const result = schema.safeParse(base); + expect(result.success).toBe(true); + if (result.success) { + expect('previewDrafts' in (result.data as object)).toBe(false); + } + }); + + it.each(previewCases)('%s rejects a non-boolean previewDrafts — a switch, not a bag', (_n, schema, base) => { + expect(schema.safeParse({ ...base, previewDrafts: 'true' }).success).toBe(false); + expect(schema.safeParse({ ...base, previewDrafts: 1 }).success).toBe(false); + }); + + it('GetMetaItemRequestSchema accepts state and PRESERVES it through parse', () => { + for (const state of ['active', 'draft'] as const) { + const result = GetMetaItemRequestSchema.safeParse({ type: 'view', name: 'account_list', state }); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data as { state?: string }).state).toBe(state); + } + } + }); + + it('GetMetaItemRequestSchema keeps state OPTIONAL and refuses values outside the vocabulary', () => { + const absent = GetMetaItemRequestSchema.safeParse({ type: 'view', name: 'account_list' }); + expect(absent.success).toBe(true); + if (absent.success) { + expect('state' in (absent.data as object)).toBe(false); + } + // The lifecycle vocabulary is CLOSED: `archived` is not a read state. + expect(GetMetaItemRequestSchema.safeParse({ type: 'view', name: 'account_list', state: 'archived' }).success).toBe(false); + expect(GetMetaItemRequestSchema.safeParse({ type: 'view', name: 'account_list', state: true }).success).toBe(false); + }); + + it('getMetaItems declares NO state — the list verb has no strict-draft mode (mirror of the inline type)', () => { + // Non-strict schema: an undeclared key parses green but is STRIPPED. + // Stripping is the observable that the member is NOT declared. + const result = GetMetaItemsRequestSchema.safeParse({ type: 'object', state: 'draft' }); + expect(result.success).toBe(true); + if (result.success) { + expect('state' in (result.data as object)).toBe(false); + } + }); + + it('cached and layered reads declare NEITHER switch — declared only where enforced', () => { + const cases = [ + [GetMetaItemCachedRequestSchema, { type: 'view', name: 'account_list' }], + [GetMetaItemLayeredRequestSchema, { type: 'view', name: 'account_list' }], + ] as const; + for (const [schema, base] of cases) { + const result = schema.safeParse({ ...base, previewDrafts: true, state: 'draft' }); + expect(result.success).toBe(true); + if (result.success) { + expect('previewDrafts' in (result.data as object)).toBe(false); + expect('state' in (result.data as object)).toBe(false); + } + } + }); +}); + +describe('environmentId stays OUT of the meta-read request shape — by decision, not omission (#9741)', () => { + // Maintainer ruling 2026-08-18 (#9741): `environmentId` is the + // TRANSPORT-level multi-kernel routing key. The REST layer resolves the + // target kernel from it BEFORE the protocol call, the implementation's + // parameter types never read it off the request, and these schemas record + // the same exclusion. This pin is the regression guard for that decision: + // if someone declares the member, the parse below stops stripping it and + // this test names the ruling they are overturning. + const cases = [ + ['GetMetaItemsRequestSchema', GetMetaItemsRequestSchema, { type: 'object' }], + ['GetMetaItemRequestSchema', GetMetaItemRequestSchema, { type: 'view', name: 'account_list' }], + ['GetMetaItemCachedRequestSchema', GetMetaItemCachedRequestSchema, { type: 'view', name: 'account_list' }], + ['GetMetaItemLayeredRequestSchema', GetMetaItemLayeredRequestSchema, { type: 'view', name: 'account_list' }], + ] as const; + + it.each(cases)('%s does not declare environmentId — a carried value is stripped by parse', (_n, schema, base) => { + const result = schema.safeParse({ ...base, environmentId: 'env_alpha' }); + expect(result.success).toBe(true); + if (result.success) { + expect('environmentId' in (result.data as object)).toBe(false); + } + }); + + it.each(cases)('%s has no environmentId in its declared shape', (_n, schema) => { + // Shape-level twin of the strip-pin above: `.shape` enumerates exactly the + // DECLARED members, so this fails even if stripping semantics ever change. + const shape = (schema as unknown as { shape: Record }).shape; + expect(Object.keys(shape)).not.toContain('environmentId'); + }); +}); + describe('MetadataProtocol declares getMetaItemLayered (#9740)', () => { // Type-level pins (compiled by the spec test typecheck, the // translation-typegen.test.ts pattern). The member is an interface diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 7f54844708..e3c5f1e655 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -213,6 +213,17 @@ export const GetMetaTypesResponseSchema = lazySchema(() => z.object({ /** * Get Metadata Items Request * Get all items of a specific metadata type + * + * **`environmentId` is deliberately NOT a member here — or on any meta-read + * request schema** (maintainer ruling 2026-08-18, #9741). It is the + * TRANSPORT-level multi-kernel routing key: the REST layer resolves the target + * kernel from it *before* the protocol call, and the implementation's own + * parameter types (`@objectstack/metadata-protocol`) never read it off the + * request. Its absence from these schemas is a recorded contract decision — + * "not part of the request" — not an undeclared-surface omission. Callers that + * must carry it alongside a request do so in a typed transport envelope at + * their own layer (see `TransportScopedMetaRequest` in `@objectstack/rest`), + * never by widening these shapes. */ export const GetMetaItemsRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name (e.g., "object", "plugin")'), @@ -224,6 +235,16 @@ export const GetMetaItemsRequestSchema = lazySchema(() => z.object({ + 'are merged into the list. Absent = environment-wide read: only env-level ' + 'overlays apply and no org partition is consulted.', ), + previewDrafts: z.boolean().optional().describe( + 'Draft-visibility switch (ADR-0033 draft-overlay preview): when true, ' + + 'pending `state=\'draft\'` rows are overlaid on the active list — draft ' + + 'wins on name collision, draft-only items appear, and each overlaid item ' + + 'is tagged `_draft: true` so UIs can badge the preview. Absent/false = ' + + 'published world only. Declaration ≠ authorization: this member only ' + + 'switches which rows are read, and ADR-0106 masking is unaffected — ' + + 'callers without draft-preview authorization are refused upstream ' + + '(admin-gated), not by this schema.', + ), })); /** @@ -249,6 +270,25 @@ export const GetMetaItemRequestSchema = lazySchema(() => z.object({ + 'is served as the item. Absent = environment-wide read: only env-level ' + 'overlays apply and no org partition is consulted.', ), + state: z.enum(['active', 'draft']).optional().describe( + 'Draft-visibility switch — which lifecycle row to read (strict mode): ' + + '`\'draft\'` opens the pending draft buffer (Studio\'s editor read) and ' + + 'fails when no draft exists; absent or `\'active\'` reads the live ' + + 'published row. Distinct from `previewDrafts`, which FALLS BACK to the ' + + 'active row when no draft exists. Declaration ≠ authorization: this ' + + 'member only selects which stored row is read — ADR-0106 masking is ' + + 'unaffected, and draft access is gated upstream, not by this schema.', + ), + previewDrafts: z.boolean().optional().describe( + 'Draft-visibility switch (ADR-0033 draft-overlay preview, non-strict): ' + + 'when true and `state` is not `\'draft\'`, a pending draft row is ' + + 'preferred if one exists, else the read falls back to the active row — ' + + 'the render path degrades to the published value instead of erroring. A ' + + 'served draft is tagged `_draft: true` so UIs can badge it. Declaration ' + + '≠ authorization: this member only switches which row is read, and ' + + 'ADR-0106 masking is unaffected — draft preview is admin-gated ' + + 'upstream, not by this schema.', + ), })); /** From 25a3c13b79d098e77d873859d0af978ed40124d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 00:05:25 +0000 Subject: [PATCH 2/2] chore: regen protocol docs; changeset (#9741) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016D9wdJR14KKCxz1WgdAzcw --- .changeset/preview-drafts-state-declared.md | 6 ++++++ content/docs/references/api/protocol.mdx | 3 +++ 2 files changed, 9 insertions(+) create mode 100644 .changeset/preview-drafts-state-declared.md diff --git a/.changeset/preview-drafts-state-declared.md b/.changeset/preview-drafts-state-declared.md new file mode 100644 index 0000000000..e0108a8943 --- /dev/null +++ b/.changeset/preview-drafts-state-declared.md @@ -0,0 +1,6 @@ +--- +"@objectstack/spec": minor +"@objectstack/rest": patch +--- + +Declare the draft-visibility switches on the meta-read request schemas, exactly where the implementation enforces them (#9741, maintainer ruling 2026-08-18): `GetMetaItemsRequestSchema` gains `previewDrafts?: boolean`, and `GetMetaItemRequestSchema` gains `state?: 'active' | 'draft'` plus `previewDrafts?: boolean`. Both members are draft-visibility switches only — declaration ≠ authorization: ADR-0106 masking is unaffected, and draft access stays admin-gated upstream. The cached and layered read requests deliberately declare neither (their implementations enforce neither). `environmentId` stays OUT of the protocol request shape by explicit ruling — it is the transport-level multi-kernel routing key, recorded schema-side as a decision rather than an omission. The REST meta-read doors (list, cached and uncached single-item, layered) drop their `as any` request casts: each request literal now compiles against the declared spec shape, with the transport-level `environmentId` carried by a typed transport envelope (`TransportScopedMetaRequest`) instead of a cast. Accept-set widening catch-up on the declared surface; zero runtime behaviour change. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index f18ed94d5f..5474e5faa0 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -753,6 +753,8 @@ Enable package response | **name** | `string` | ✅ | Item name (snake_case identifier) | | **packageId** | `string` | optional | Optional package ID to filter items by | | **organizationId** | `string` | optional | Organization (tenant) scope for the read. Selects the org partition in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — so it decides which tenant's customization row is served as the item. Absent = environment-wide read: only env-level overlays apply and no org partition is consulted. | +| **state** | `Enum<'active' \| 'draft'>` | optional | Draft-visibility switch — which lifecycle row to read (strict mode): `'draft'` opens the pending draft buffer (Studio's editor read) and fails when no draft exists; absent or `'active'` reads the live published row. Distinct from `previewDrafts`, which FALLS BACK to the active row when no draft exists. Declaration ≠ authorization: this member only selects which stored row is read — ADR-0106 masking is unaffected, and draft access is gated upstream, not by this schema. | +| **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview, non-strict): when true and `state` is not `'draft'`, a pending draft row is preferred if one exists, else the read falls back to the active row — the render path degrades to the published value instead of erroring. A served draft is tagged `_draft: true` so UIs can badge it. Declaration ≠ authorization: this member only switches which row is read, and ADR-0106 masking is unaffected — draft preview is admin-gated upstream, not by this schema. | --- @@ -789,6 +791,7 @@ Enable package response | **type** | `string` | ✅ | Metadata type name (e.g., "object", "plugin") | | **packageId** | `string` | optional | Optional package ID to filter items by | | **organizationId** | `string` | optional | Organization (tenant) scope for the read. Selects the org partition in the ADR-0005 overlay read order — org overlay wins over env-wide overlay wins over packaged artifact — so it decides which tenant's customization rows are merged into the list. Absent = environment-wide read: only env-level overlays apply and no org partition is consulted. | +| **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview): when true, pending `state='draft'` rows are overlaid on the active list — draft wins on name collision, draft-only items appear, and each overlaid item is tagged `_draft: true` so UIs can badge the preview. Absent/false = published world only. Declaration ≠ authorization: this member only switches which rows are read, and ADR-0106 masking is unaffected — callers without draft-preview authorization are refused upstream (admin-gated), not by this schema. | ---