Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/preview-drafts-state-declared.md
Original file line numberDiff line numberDiff line change
@@ -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.
3 changes: 3 additions & 0 deletions content/docs/references/api/protocol.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |


---
Expand DownExpand Up@@ -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. |


---
Expand Down
73 changes: 64 additions & 9 deletions packages/rest/src/rest-server.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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> = R & { environmentId?: string };
import {
buildFieldMetaMap,
referenceFieldNames,
Expand DownExpand Up@@ -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<GetMetaItemLayeredRequest> = {
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
Expand DownExpand Up@@ -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<GetMetaItemsRequest> = {
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
Expand DownExpand Up@@ -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<GetMetaItemCachedRequest> = {
type: req.params.type,
name: req.params.name,
cacheRequest,
Expand All@@ -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();
Expand DownExpand Up@@ -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<string, any>;
};
const envelope = await p.getMetaItem(itemRequest) as Record<string, any>;

// [#5563] `getMetaItem` answers the envelope
// `{ type, name, item, lock, … }`. Unwrap ONCE here;
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/authorable-surface/api.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand All@@ -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",
Expand Down
115 changes: 115 additions & 0 deletions packages/spec/src/api/protocol.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown> }).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
Expand Down
Loading
Loading