Skip to content

Commit 67ceb9a

Browse files
claude[bot]claude
andauthored
fix(runtime): the dispatcher /metadata transport folds the URL segment before the org-scope decision (#11553)
* fix(runtime): the dispatcher /metadata transport folds the url segment before the org-scope decision The dispatcher took `type` verbatim from the path and handed it RAW to `organizationIdForMetaWrite`, while `protocol.saveMetaItem` folds the same string through `canonicalizeMetaRequestType` for storage. Two maps that must agree did not: storage folds through `META_URL_TO_SINGULAR` (every spelling), `declaresOrgOverride` tolerates only the manifest-collection spellings. For the two URL-only spellings of `allowOrgOverride: true` types — `translations` and `email_templates` — an org-active caller's write landed env-wide where the singular twin landed org-scoped: one item, two partitions, addressed by spelling. The `/published` branch is the smaller second site: after the layered consult misses, the code/package store is keyed by canonical type, so a raw plural answered 404 for an item the singular answered 200 for. Both sites now fold through `canonicalMetaUrlType` at the boundary — the correction #10340 landed for REST, and the one metadata-url-spelling.ts mandates. Not by widening `declaresOrgOverride`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR * chore(changeset): dispatcher /metadata org-scope url-spelling fold Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fbb5020 commit 67ceb9a

3 files changed

Lines changed: 408 additions & 7 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
The dispatcher `/metadata` transport folds the URL segment before deciding
6+
organization scope — `/metadata/translations/:name` no longer writes to a
7+
different partition than `/metadata/translation/:name`
8+
9+
Two maps that must agree did not. `protocol.saveMetaItem` folds the path
10+
segment through `canonicalizeMetaRequestType``META_URL_TO_SINGULAR`, the
11+
**complete** spelling map, for storage. The dispatcher handed the same string
12+
**raw** to `organizationIdForMetaWrite`, whose `declaresOrgOverride` tolerates
13+
only the manifest-collection spellings — incomplete by design.
14+
15+
For the two URL-only spellings of `allowOrgOverride: true` types the two
16+
answers diverged. `translation` has no manifest collection key at all;
17+
`email_template`'s is the camelCase `emailTemplates`, so the snake_case plural
18+
the registry derivation adds is URL-only too:
19+
20+
```
21+
PUT /metadata/translation/:name → org-scoped row (correct)
22+
PUT /metadata/translations/:name → env-wide row (the defect)
23+
PUT /metadata/email_template/:name → org-scoped row (correct)
24+
PUT /metadata/email_templates/:name → env-wide row (the defect)
25+
```
26+
27+
Storage folded both spellings to the same canonical type, so the rows differed
28+
in `organization_id` alone: one item in two partitions, addressed by spelling.
29+
Measured end-to-end through the real dispatcher, protocol and repository —
30+
writing an item under both spellings left **two** `sys_metadata` rows where
31+
there should be one, and the env-wide one is shadowed by every read the
32+
org-active author makes. Persisted, receipted 200, served by nothing.
33+
34+
`GET /metadata/:type/:name/published` is the smaller second site of the same
35+
class. After the layered overlay consult misses, the fallback reads the
36+
code/package store, which is keyed by canonical type; handed the raw segment it
37+
answered **404** under a recognised plural for an item the singular twin
38+
answered **200** for.
39+
40+
Both sites now fold through `canonicalMetaUrlType` at the boundary — the
41+
correction the REST `/meta` doors already carry, and the one
42+
`metadata-url-spelling.ts` mandates ("folding happens at the boundary and only
43+
there; the layers below keep reading the single canonical singular"). ⛔ Not by
44+
widening `declaresOrgOverride`: a predicate below the boundary consuming the
45+
URL spelling contract is the repair that module's header forbids.
46+
47+
Only the scope **argument** is folded. The request `type` stays the raw
48+
segment, exactly as the REST doors leave it — the protocol boundary folds it
49+
itself, and two pre-folds would hide a drift between them from the protocol's
50+
own tests. A type the contract does not map (a plugin-registered kind such as
51+
`webhook`) still reaches the store verbatim: the fold is a lookup, never a
52+
spelling guesser.
53+
54+
⚠️ Whether real callers reach this transport with plural spellings has **not**
55+
been measured. The REST transport was the measured, user-visible surface; this
56+
one is corrected so the class is closed on both transports rather than one.

packages/runtime/src/domains/meta.ts

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
import {
1212
shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE,
1313
} from '@objectstack/core';
14-
import { pluralToSingular } from '@objectstack/spec/shared';
14+
// [#10503] `canonicalMetaUrlType` is the FOLD this transport was missing.
15+
// See the two call sites below for what each one was deciding raw.
16+
import { canonicalMetaUrlType, pluralToSingular } from '@objectstack/spec/shared';
1517
import { CoreServiceName } from '@objectstack/spec/system';
1618
// [ADR-0106 / #3682] Metadata-plane FLS — the SAME projection the REST `/meta`
1719
// exits run. Two dispatchers, one normalizer (`@objectstack/metadata-core`),
@@ -302,15 +304,23 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin
302304

303305
const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata);
304306
if (metadataService && typeof (metadataService as any).getPublished === 'function') {
305-
const data = await (metadataService as any).getPublished(type, name);
307+
// [#10503] FOLDED — the smaller second site of the same class,
308+
// dispatcher edition (the REST twin folds at the same point). The
309+
// layered consult above folds internally at the protocol boundary;
310+
// this fallback reads the code/package registry, which stores
311+
// CANONICAL types. Handed the raw segment it answered 404 under a
312+
// recognised plural and 200 under the singular twin — of the same
313+
// code-published item.
314+
const data = await (metadataService as any).getPublished(canonicalMetaUrlType(type), name);
306315
if (data === undefined) return { handled: true, response: deps.error('Not found', 404) };
307316
return { handled: true, response: deps.success(data) };
308317
}
309318
// Fallback — try MetadataService via resolveService
310319
const metaSvc = await deps.resolveService(_context, 'metadata', _context.environmentId);
311320
if (metaSvc && typeof (metaSvc as any).getPublished === 'function') {
312321
try {
313-
const fallbackData = await (metaSvc as any).getPublished(type, name);
322+
// [#10503] Same fold — this slot reads the same canonical store.
323+
const fallbackData = await (metaSvc as any).getPublished(canonicalMetaUrlType(type), name);
314324
if (fallbackData !== undefined) return { handled: true, response: deps.success(fallbackData) };
315325
} catch { /* fall through */ }
316326
}
@@ -413,7 +423,36 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin
413423
// `isOverlayAllowed` — and, since #8805, why it lives there:
414424
// the REST `/meta` write doors run the same one.
415425
const activeOrganizationId = await deps.resolveActiveOrganizationId(_context);
416-
const organizationId = organizationIdForMetaWrite(type, activeOrganizationId);
426+
//
427+
// [#10503] The segment is FOLDED before the scope decision
428+
// — the correction #10340 landed for the REST `/meta`
429+
// doors, arriving on the second transport. This branch read
430+
// the RAW `parts[0]`, while `protocol.saveMetaItem` below
431+
// folds the same string through `canonicalizeMetaRequestType`
432+
// for storage. Two maps that must agree did not: storage
433+
// folds through `META_URL_TO_SINGULAR` (every spelling),
434+
// while `declaresOrgOverride` tolerates only the MANIFEST
435+
// collection spellings. For the two URL-only spellings of
436+
// `allowOrgOverride: true` types — `translations` and
437+
// `email_templates` — an org-active caller's write therefore
438+
// landed ENV-WIDE where the singular twin landed org-scoped:
439+
// one item, two partitions, addressed by spelling.
440+
//
441+
// ⛔ NOT repaired by widening `declaresOrgOverride`'s set —
442+
// a predicate below the boundary consuming the URL spelling
443+
// contract is what `metadata-url-spelling.ts`'s own header
444+
// forbids ("folding happens at the boundary and only
445+
// there"), and `meta-write-org-scope.ts`'s
446+
// `ORG_OVERRIDABLE_TYPES` header pins that limit.
447+
//
448+
// Only the scope ARGUMENT is folded. The request `type`
449+
// stays the raw segment, exactly as the REST doors leave
450+
// it: the protocol boundary folds it itself, and two
451+
// pre-folds would hide a drift between them from the
452+
// protocol's own tests.
453+
const organizationId = organizationIdForMetaWrite(
454+
canonicalMetaUrlType(type), activeOrganizationId,
455+
);
417456
// [#10888] Server-stated face: this branch answers through
418457
// `deps.errorFromThrown`, which carries the refusal's
419458
// `issues[]` in `details` (see the `details.issues` pin in

0 commit comments

Comments
 (0)