Skip to content

Commit 494a519

Browse files
hotlongclaude
andcommitted
refactor(rest, runtime): the layered chain starts after the store read, as the item chain does
`createMetaLayeredAnswer` now takes each transport's read answer, like `createMetaItemAnswer`: the read stays in `RestServer`'s helper and in the dispatcher, each scoped by `metaReadOrganizationId`. `RestServer` keeps its own execution-context site there, so the exec-ctx consumer census holds at 66 sites and 90 mentions, unedited. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 59810b7 commit 494a519

4 files changed

Lines changed: 157 additions & 152 deletions

File tree

‎packages/rest/src/index.ts‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,12 @@ export { refuseRepeatedQueryParams, repeatedQueryParamMessage } from './query-mu
107107
// context (`metaCallerOrganizationId`, and `metaReadOrganizationId` for a read
108108
// of one type).
109109
//
110-
// [#20478] …and the layered view's, on both of its spellings: the whole answer
111-
// (`createMetaLayeredAnswer` — the read in the caller's vetted partition, the
112-
// per-caller gate on every layer under the stored-version doors' policy, the
113-
// object mask and its cache posture), the deprecated `?layers=` flag's parse
114-
// (`wantsMetaItemLayers`) and the headers it is served under
115-
// (`metaItemLayersDeprecationHeaders`).
110+
// [#20478] …and the layered view's, on both of its spellings: its post-read
111+
// chain (`createMetaLayeredAnswer` — the per-caller gate on every layer under
112+
// the stored-version doors' policy, the object mask and its cache posture), the
113+
// deprecated `?layers=` flag's parse (`wantsMetaItemLayers`) and the headers it
114+
// is served under (`metaItemLayersDeprecationHeaders`). The read itself is each
115+
// transport's, scoped by `metaReadOrganizationId`.
116116
export {
117117
createMetaBookTreeAnswer,
118118
createMetaItemAnswer,
@@ -142,7 +142,6 @@ export type {
142142
MetaItemReadVerdict,
143143
MetaItemRequest,
144144
MetaLayeredAnswer,
145-
MetaLayeredAnswerSources,
146145
MetaLayeredRequest,
147146
MetaListAnswer,
148147
MetaListAnswerSources,

‎packages/rest/src/meta-item-read-gate.ts‎

Lines changed: 70 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
import type { AudienceCaller, Book, ResolvedBook, ResolverDoc } from '@objectstack/spec/system';
6161
import { preferredLocaleFromHeader } from '@objectstack/spec/system';
6262
import { apiExposureDenialReason } from '@objectstack/spec/data';
63-
import { resolveObjectSortability, type GetMetaItemLayeredRequest } from '@objectstack/spec/api';
63+
import { resolveObjectSortability } from '@objectstack/spec/api';
6464
import { canonicalMetaUrlType, pluralToSingular, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared';
6565
import {
6666
ObjectSchemaMaskEvaluationError,
@@ -2496,36 +2496,11 @@ const META_ITEM_LAYERS = ['effective', 'code', 'overlay'] as const;
24962496
/** The layers in the order the ADR-0106 mask projects them. */
24972497
const META_ITEM_MASKED_LAYERS = ['code', 'overlay', 'effective'] as const;
24982498

2499-
/** Everything {@link createMetaLayeredAnswer} reads, supplied by the transport. */
2500-
export interface MetaLayeredAnswerSources extends MetaItemReadGateSources {
2501-
/**
2502-
* The protocol's layered read (`getMetaItemLayered`), handed the request
2503-
* this chain builds — the item, its `?package=` scope (ADR-0048) and the
2504-
* caller's organization — plus whatever the transport scopes every read by
2505-
* itself (`RestServer`: its environment). A THROW propagates: the transport
2506-
* answers that fault, ⛔ never a layered view with a layer missing.
2507-
*
2508-
* ⚠️ {@link MetaReadGateAudienceSources.resolveCaller} MUST carry
2509-
* {@link MetaReadGateCaller.mayWriteItem} here — the transport's own save
2510-
* door's admission of the item — because the layered view is a door that
2511-
* honours the author exemption ({@link STORED_VERSION_DOOR_POLICY}). Absent
2512-
* reads as `false`: every caller is served the pruned layers.
2513-
*/
2514-
readLayered(request: GetMetaItemLayeredRequest): Promise<unknown>;
2515-
}
2516-
25172499
/** The request facts the layered chain reads — no transport shape. */
25182500
export interface MetaLayeredRequest {
2519-
/**
2520-
* The `:type` segment AS RECEIVED. The protocol folds it at its own
2521-
* boundary, so it is handed down unfolded (a pre-fold here would hide a
2522-
* drift between the two from the protocol's tests); the gate and the
2523-
* organization are decided on the fold, here.
2524-
*/
2525-
readonly type: string;
2501+
/** The SINGULAR type (the caller folds `/meta/apps/:name` once, at its boundary). */
2502+
readonly metaType: string;
25262503
readonly name: string;
2527-
/** `?package=` — ONE owning package (ADR-0048), or `undefined`. */
2528-
readonly packageId?: string;
25292504
/**
25302505
* [ADR-0106 D2/D3] The caller's field-visibility posture for this item,
25312506
* resolved by the transport BEFORE the read, the not-applicable passthrough
@@ -2554,96 +2529,100 @@ export type MetaLayeredAnswer =
25542529
/**
25552530
* [#5882 · #20156 · #20478] THE answer of the layered view — the three-layer
25562531
* diagnostic projection (`code` / `overlay` / `effective`) declared by
2557-
* `GetMetaItemLayeredResponseSchema` — on both of its spellings
2558-
* (`GET /meta/:type/:name/layers`, and the deprecated `?layers=` flag on the item
2559-
* read), on both transports.
2532+
* `GetMetaItemLayeredResponseSchema` — after the store read, on both of its
2533+
* spellings (`GET /meta/:type/:name/layers`, and the deprecated `?layers=` flag
2534+
* on the item read), on both transports.
25602535
*
2561-
* ## Why here
2536+
* ## Why one chain
25622537
*
25632538
* `RestServer` served both spellings through one private helper, and the
25642539
* runtime dispatcher — the only answer on a host that mounts just the
25652540
* `${prefix}/*` catch-all — served neither: the route answered a located
25662541
* `404 ROUTE_NOT_FOUND`, and the flag answered the PLAIN read's
25672542
* `{ type, name, item }` with a `200`, so a client reading `overlay` or
2568-
* `effective` there read `undefined`, and an author was served the item pruned
2569-
* where ruling 5856774816 serves it whole. The helper moved here, unchanged, and
2570-
* each transport supplies only its I/O. ⛔ A step is added HERE, never in a
2571-
* transport — one added in one of them is the defect this closed, reopened.
2543+
* `effective` there read `undefined`, and an author was served the app pruned
2544+
* where ruling 5856774816 serves it whole. Everything the helper did after the
2545+
* read moved here, unchanged, and each transport hands its read's answer to
2546+
* THIS function. ⛔ A step is added HERE, never in a transport — one added in
2547+
* one of them is the defect this closed, reopened.
25722548
* `meta-list-projection-parity.test.ts` in `@objectstack/runtime` drives both
25732549
* spellings through both transports.
25742550
*
2551+
* The read stays each transport's, in the caller's VETTED partition
2552+
* ({@link metaReadOrganizationId} over the folded type — the partition the plain
2553+
* read reads, [#9454] so an author who has just saved an org overlay is not
2554+
* shown `overlay: null`) and its `?package=` scope (ADR-0048), exactly as the
2555+
* item read's does ({@link createMetaItemAnswer}).
2556+
*
25752557
* Not translated and not cached, both deliberately: this is a diagnostic view of
25762558
* what is STORED at each layer, so locale-collapsing it (or serving it from the
25772559
* published-value cache) would misreport the thing being diagnosed.
25782560
*
25792561
* ## The steps, in `RestServer`'s order (unchanged)
25802562
*
2581-
* 1. The read, scoped to the caller's VETTED organization
2582-
* ({@link metaReadOrganizationId} over the folded type — the partition the
2583-
* plain read reads, [#9454] so an author who has just saved an org overlay
2584-
* is not shown `overlay: null`) and to `?package=` (ADR-0048).
2585-
* 2. [#20156] THE per-caller gate on EVERY present layer, `effective` first,
2586-
* under {@link STORED_VERSION_DOOR_POLICY}: per-caller arms only (these are
2587-
* STORED versions, which Studio's designer loads and saves back), and
2588-
* ruling 5856774816 — a caller who may write an app reads every layer
2589-
* whole, and any other caller who may open it reads each layer pruned,
2590-
* exactly as the plain read prunes it. Every layer is judged before any is
2591-
* served, so a refusal sends nothing of the others.
2592-
* 3. [ADR-0106 D5(4)] The mask on every layer — each is a full object schema —
2563+
* 1. [#20156] THE per-caller gate on EVERY present layer, `effective` first
2564+
* (it is what the plain read serves, so its refusal is the plain read's
2565+
* own), under {@link STORED_VERSION_DOOR_POLICY}: per-caller arms only
2566+
* (these are STORED versions, which Studio's designer loads and saves
2567+
* back), and ruling 5856774816 — a caller who may write the item reads
2568+
* every layer whole, and any other caller who may open it reads each layer
2569+
* pruned, exactly as the plain read prunes it. ⚠️ So the transport's
2570+
* {@link MetaReadGateAudienceSources.resolveCaller} MUST carry
2571+
* {@link MetaReadGateCaller.mayWriteItem} — its own save door's admission;
2572+
* absent reads as `false` (every caller pruned). Every layer is judged
2573+
* before any is served, so a refusal sends nothing of the others.
2574+
* 2. [ADR-0106 D5(4)] The mask on every layer — each is a full object schema —
25932575
* through {@link projectMetaObjectSchema} under the posture resolved before
25942576
* the read, and the `private, no-store` an undetermined posture owes.
25952577
*
2596-
* The protocol's answer is never mutated. A layer the gate or the mask leaves
2597-
* as it was is served as it was, and every other key of the answer
2578+
* The protocol's answer is never mutated. A layer the gate or the mask leaves as
2579+
* it was is served as it was, and every other key of the answer
25982580
* (`overlayScope`, `_diagnostics`, the ADR-0010 protection envelope) rides
25992581
* through untouched. With nothing behind the name the protocol answers every
2600-
* layer `null`, and so does this chain: no layer is present to judge.
2582+
* layer `null`, and so does this chain: no layer is present to judge. A gate
2583+
* input that cannot be read REJECTS: the transport answers that fault, ⛔ never
2584+
* a layered view with a layer missing.
26012585
*/
2602-
export async function createMetaLayeredAnswer(
2603-
sources: MetaLayeredAnswerSources,
2586+
export function createMetaLayeredAnswer(
2587+
sources: MetaItemReadGateSources,
26042588
request: MetaLayeredRequest,
2605-
): Promise<MetaLayeredAnswer> {
2606-
const { type, name, packageId, maskPosture } = request;
2607-
2608-
// 1. [#9454 · #20408] The read, in the caller's vetted partition.
2609-
const organizationId = metaReadOrganizationId(type, await sources.resolveCaller());
2610-
const layered: any = await sources.readLayered({
2611-
type,
2612-
name,
2613-
...(packageId ? { packageId } : {}),
2614-
...(organizationId ? { organizationId } : {}),
2615-
});
2589+
): (layered: unknown) => Promise<MetaLayeredAnswer> {
2590+
const { metaType, name, maskPosture } = request;
2591+
return async (raw) => {
2592+
const layered = raw as Record<string, unknown> | null | undefined;
26162593

2617-
// 2. [#20156] THE per-caller gate, on every present layer.
2618-
const served = new Map<string, unknown>();
2619-
{
2620-
const present = META_ITEM_LAYERS.filter((layer) => layered?.[layer] != null);
2621-
const judge = createMetaItemReadGate(
2622-
sources, pluralToSingular(type), name, present.map((layer) => layered[layer]), STORED_VERSION_DOOR_POLICY,
2623-
);
2624-
for (const layer of present) {
2625-
const verdict = await judge(layered[layer]);
2626-
if (verdict.kind === 'refuse') return verdict;
2627-
served.set(layer, verdict.document);
2594+
// 1. [#20156] THE per-caller gate, on every present layer.
2595+
const served = new Map<string, unknown>();
2596+
{
2597+
const present = META_ITEM_LAYERS.filter((layer) => layered?.[layer] != null);
2598+
const judge = createMetaItemReadGate(
2599+
sources, metaType, name, present.map((layer) => layered![layer]), STORED_VERSION_DOOR_POLICY,
2600+
);
2601+
for (const layer of present) {
2602+
const verdict = await judge(layered![layer]);
2603+
if (verdict.kind === 'refuse') return verdict;
2604+
served.set(layer, verdict.document);
2605+
}
26282606
}
2629-
}
26302607

2631-
// 3. [ADR-0106 D5(4)] The mask, on every layer.
2632-
let cacheControl: typeof META_UNDETERMINED_CACHE_CONTROL | undefined;
2633-
for (const layer of META_ITEM_MASKED_LAYERS) {
2634-
const document = served.has(layer) ? served.get(layer) : layered?.[layer];
2635-
const masked = projectMetaObjectSchema(maskPosture, document);
2636-
if (!masked.ok) return { kind: 'mask-fault', object: name };
2637-
cacheControl ??= masked.cacheControl;
2638-
if (masked.document !== document) served.set(layer, masked.document);
2639-
}
2608+
// 2. [ADR-0106 D5(4)] The mask, on every layer.
2609+
let cacheControl: typeof META_UNDETERMINED_CACHE_CONTROL | undefined;
2610+
for (const layer of META_ITEM_MASKED_LAYERS) {
2611+
const document = served.has(layer) ? served.get(layer) : layered?.[layer];
2612+
const masked = projectMetaObjectSchema(maskPosture, document);
2613+
if (!masked.ok) return { kind: 'mask-fault', object: name };
2614+
cacheControl ??= masked.cacheControl;
2615+
if (masked.document !== document) served.set(layer, masked.document);
2616+
}
26402617

2641-
if (!layered || typeof layered !== 'object') {
2642-
return cacheControl ? { kind: 'serve', layered, cacheControl } : { kind: 'serve', layered };
2643-
}
2644-
const answer: Record<string, unknown> = { ...layered };
2645-
for (const [layer, document] of served) answer[layer] = document;
2646-
return cacheControl ? { kind: 'serve', layered: answer, cacheControl } : { kind: 'serve', layered: answer };
2618+
let answer: unknown = raw;
2619+
if (layered && typeof layered === 'object') {
2620+
const replaced: Record<string, unknown> = { ...layered };
2621+
for (const [layer, document] of served) replaced[layer] = document;
2622+
answer = replaced;
2623+
}
2624+
return cacheControl ? { kind: 'serve', layered: answer, cacheControl } : { kind: 'serve', layered: answer };
2625+
};
26472626
}
26482627

26492628
// ── THE book tree ─────────────────────────────────────────────────────────────

‎packages/rest/src/rest-server.ts‎

Lines changed: 53 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3716,16 +3716,15 @@ export class RestServer {
37163716
* old spelling answers *the same body* — two copies would let that stop
37173717
* being true without anything failing.
37183718
*
3719-
* [#20478] …and behind two TRANSPORTS: the answer is
3720-
* `createMetaLayeredAnswer` in `./meta-item-read-gate.ts` — the read in the
3721-
* caller's vetted organization (#9454) and `?package=` scope (ADR-0048), THE
3722-
* per-caller gate on every layer under the stored-version doors' policy
3723-
* (#20156, ruling 5856774816: whole for whoever may write the item, pruned
3724-
* as the plain read prunes it for everyone else), and the ADR-0106 mask on
3725-
* every layer with its cache posture — which the runtime dispatcher serves
3726-
* both spellings through too. ⛔ A step is added there, never here. This
3727-
* method keeps what is this transport's own: the ingress refusal of a
3728-
* repeated `?package=`, its environment, and its wire.
3719+
* [#20478] …and behind two TRANSPORTS: everything after the store read is
3720+
* `createMetaLayeredAnswer` in `./meta-item-read-gate.ts` — THE per-caller
3721+
* gate on every layer under the stored-version doors' policy (#20156,
3722+
* ruling 5856774816: whole for whoever may write the item, pruned as the
3723+
* plain read prunes it for everyone else) and the ADR-0106 mask on every
3724+
* layer with its cache posture — which the runtime dispatcher serves both
3725+
* spellings through too. ⛔ A step is added there, never here. The read
3726+
* stays this transport's, scoped by `metaReadOrganizationId`, the one
3727+
* answer the dispatcher's layered read asks.
37293728
*
37303729
* Not translated and not cached, both deliberately: this is a diagnostic
37313730
* view of what is STORED at each layer, so locale-collapsing it (or serving
@@ -3739,36 +3738,56 @@ export class RestServer {
37393738
p: any,
37403739
maskPosture: ObjectSchemaMaskPosture,
37413740
): Promise<void> {
3741+
// ADR-0048 — thread `?package=` so the layered (Studio editor) view is
3742+
// package-scoped; the editor passes the edited item's owning package,
3743+
// not the studio app's.
3744+
//
37423745
// [#6877] ONE owning package, so repetition is refused rather than
37433746
// resolved: `?package=a&package=b` used to reach
37443747
// `getMetaItemLayered({ packageId: ['a','b'] })`. Gated in the helper,
37453748
// not in its two callers, so both entry points answer identically.
37463749
if (refuseRepeatedQueryParams(req, res, ['package'])) return;
3750+
const layeredPackageId = req.query?.package || undefined;
3751+
// [#9454] State the ORG scope, exactly as the `/published` overlay read
3752+
// already does. Without it the layered view resolved the env-wide row
3753+
// only, so an author who had just saved an org overlay opened Studio to
3754+
// `overlay: null` and the code layer — the write receipted as live, the
3755+
// editor reporting it absent. This is the DIAGNOSTIC view of what is
3756+
// stored per layer, so an unstated scope does not merely miss a row: it
3757+
// misreports the very thing being diagnosed.
3758+
// ⚠️ NOT a new org-resolution seam — `resolveExecCtx` is memoised per
3759+
// request (WeakMap keyed by `req`), the same result 40+ handlers here
3760+
// already share. Registry-gated via `organizationIdForMetaRead` so a
3761+
// non-overridable type keeps reading env-wide (see that predicate for
3762+
// why naming the org unconditionally would resurrect #6190's phantoms).
3763+
const layeredCtx = await this.resolveExecCtx(environmentId, req)
3764+
.catch(rethrowAuthzStoreUnavailable);
3765+
// [#10340] FOLDED, not raw — see the PUT door's org-scope comment for
3766+
// the measurement. [#20478] Asked of `metaReadOrganizationId` (the
3767+
// same fold over the vetted `tenantId`), the one answer the runtime
3768+
// dispatcher's layered read asks too.
3769+
const layeredOrganizationId = metaReadGate.metaReadOrganizationId(req.params.type, layeredCtx);
3770+
// [#9741] This door never carried an `as any`, but `p: any` meant its
3771+
// request literal was never checked either — the same blind spot with
3772+
// a different spelling. Typing the literal (spec shape + the
3773+
// transport-level `environmentId`, see `TransportScopedMetaRequest`)
3774+
// makes an undeclared key a compile error here too.
3775+
const layeredRequest: TransportScopedMetaRequest<GetMetaItemLayeredRequest> = {
3776+
type: req.params.type,
3777+
name: req.params.name,
3778+
...(layeredPackageId ? { packageId: layeredPackageId } : {}),
3779+
...(environmentId ? { environmentId } : {}),
3780+
...(layeredOrganizationId ? { organizationId: layeredOrganizationId } : {}),
3781+
};
3782+
const layered = await p.getMetaItemLayered(layeredRequest);
3783+
// [#20156 · #20478] THE per-caller gate on every layer, then the mask —
3784+
// the shared chain. The stored-version doors honour the author
3785+
// exemption, so the caller carries this transport's save-door
3786+
// admission (`metaItemReadGateSources(…, true)`).
37473787
const answer = await metaReadGate.createMetaLayeredAnswer(
3748-
{
3749-
// [#20156] The stored-version doors honour the author exemption,
3750-
// so the caller carries this transport's save-door admission.
3751-
...this.metaItemReadGateSources(environmentId, req, p, true),
3752-
// [#9741] Typed: the spec shape plus the transport-level
3753-
// `environmentId` (see `TransportScopedMetaRequest`), so an
3754-
// undeclared key is a compile error here too.
3755-
readLayered: (request) => {
3756-
const layeredRequest: TransportScopedMetaRequest<GetMetaItemLayeredRequest> = {
3757-
...request,
3758-
...(environmentId ? { environmentId } : {}),
3759-
};
3760-
return p.getMetaItemLayered(layeredRequest);
3761-
},
3762-
},
3763-
{
3764-
type: req.params.type,
3765-
name: req.params.name,
3766-
// ADR-0048 — the editor passes the edited item's owning
3767-
// package, not the studio app's.
3768-
packageId: req.query?.package || undefined,
3769-
maskPosture,
3770-
},
3771-
);
3788+
this.metaItemReadGateSources(environmentId, req, p, true),
3789+
{ metaType: RestServer.metaTypeSingular(req.params.type), name: req.params.name, maskPosture },
3790+
)(layered);
37723791
switch (answer.kind) {
37733792
case 'refuse':
37743793
RestServer.sendMetaReadRefusal(res, answer.refusal);

0 commit comments

Comments
 (0)