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
56 changes: 56 additions & 0 deletions .changeset/dispatcher-meta-org-scope-url-spelling.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
---
'@objectstack/runtime': patch
---

The dispatcher `/metadata` transport folds the URL segment before deciding
organization scope — `/metadata/translations/:name` no longer writes to a
different partition than `/metadata/translation/:name`

Two maps that must agree did not. `protocol.saveMetaItem` folds the path
segment through `canonicalizeMetaRequestType` → `META_URL_TO_SINGULAR`, the
**complete** spelling map, for storage. The dispatcher handed the same string
**raw** to `organizationIdForMetaWrite`, whose `declaresOrgOverride` tolerates
only the manifest-collection spellings — incomplete by design.

For the two URL-only spellings of `allowOrgOverride: true` types the two
answers diverged. `translation` has no manifest collection key at all;
`email_template`'s is the camelCase `emailTemplates`, so the snake_case plural
the registry derivation adds is URL-only too:

```
PUT /metadata/translation/:name → org-scoped row (correct)
PUT /metadata/translations/:name → env-wide row (the defect)
PUT /metadata/email_template/:name → org-scoped row (correct)
PUT /metadata/email_templates/:name → env-wide row (the defect)
```

Storage folded both spellings to the same canonical type, so the rows differed
in `organization_id` alone: one item in two partitions, addressed by spelling.
Measured end-to-end through the real dispatcher, protocol and repository —
writing an item under both spellings left **two** `sys_metadata` rows where
there should be one, and the env-wide one is shadowed by every read the
org-active author makes. Persisted, receipted 200, served by nothing.

`GET /metadata/:type/:name/published` is the smaller second site of the same
class. After the layered overlay consult misses, the fallback reads the
code/package store, which is keyed by canonical type; handed the raw segment it
answered **404** under a recognised plural for an item the singular twin
answered **200** for.

Both sites now fold through `canonicalMetaUrlType` at the boundary — the
correction the REST `/meta` doors already carry, and the one
`metadata-url-spelling.ts` mandates ("folding happens at the boundary and only
there; the layers below keep reading the single canonical singular"). ⛔ Not by
widening `declaresOrgOverride`: a predicate below the boundary consuming the
URL spelling contract is the repair that module's header forbids.

Only the scope **argument** is folded. The request `type` stays the raw
segment, exactly as the REST doors leave it — the protocol boundary folds it
itself, and two pre-folds would hide a drift between them from the protocol's
own tests. A type the contract does not map (a plugin-registered kind such as
`webhook`) still reaches the store verbatim: the fold is a lookup, never a
spelling guesser.

⚠️ Whether real callers reach this transport with plural spellings has **not**
been measured. The REST transport was the measured, user-visible surface; this
one is corrected so the class is closed on both transports rather than one.
47 changes: 43 additions & 4 deletions packages/runtime/src/domains/meta.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,7 +11,9 @@
import {
shouldDenyAnonymous, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE,
} from '@objectstack/core';
import { pluralToSingular } from '@objectstack/spec/shared';
// [#10503] `canonicalMetaUrlType` is the FOLD this transport was missing.
// See the two call sites below for what each one was deciding raw.
import { canonicalMetaUrlType, pluralToSingular } from '@objectstack/spec/shared';
import { CoreServiceName } from '@objectstack/spec/system';
// [ADR-0106 / #3682] Metadata-plane FLS — the SAME projection the REST `/meta`
// exits run. Two dispatchers, one normalizer (`@objectstack/metadata-core`),
Expand DownExpand Up@@ -302,15 +304,23 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin

const metadataService = await deps.getService(_context, CoreServiceName.enum.metadata);
if (metadataService && typeof (metadataService as any).getPublished === 'function') {
const data = await (metadataService as any).getPublished(type, name);
// [#10503] FOLDED — the smaller second site of the same class,
// dispatcher edition (the REST twin folds at the same point). The
// layered consult above folds internally at the protocol boundary;
// this fallback reads the code/package registry, which stores
// CANONICAL types. Handed the raw segment it answered 404 under a
// recognised plural and 200 under the singular twin — of the same
// code-published item.
const data = await (metadataService as any).getPublished(canonicalMetaUrlType(type), name);
if (data === undefined) return { handled: true, response: deps.error('Not found', 404) };
return { handled: true, response: deps.success(data) };
}
// Fallback — try MetadataService via resolveService
const metaSvc = await deps.resolveService(_context, 'metadata', _context.environmentId);
if (metaSvc && typeof (metaSvc as any).getPublished === 'function') {
try {
const fallbackData = await (metaSvc as any).getPublished(type, name);
// [#10503] Same fold — this slot reads the same canonical store.
const fallbackData = await (metaSvc as any).getPublished(canonicalMetaUrlType(type), name);
if (fallbackData !== undefined) return { handled: true, response: deps.success(fallbackData) };
} catch { /* fall through */ }
}
Expand DownExpand Up@@ -413,7 +423,36 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin
// `isOverlayAllowed` — and, since #8805, why it lives there:
// the REST `/meta` write doors run the same one.
const activeOrganizationId = await deps.resolveActiveOrganizationId(_context);
const organizationId = organizationIdForMetaWrite(type, activeOrganizationId);
//
// [#10503] The segment is FOLDED before the scope decision
// — the correction #10340 landed for the REST `/meta`
// doors, arriving on the second transport. This branch read
// the RAW `parts[0]`, while `protocol.saveMetaItem` below
// folds the same string through `canonicalizeMetaRequestType`
// for storage. Two maps that must agree did not: storage
// folds through `META_URL_TO_SINGULAR` (every spelling),
// while `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 therefore
// landed ENV-WIDE where the singular twin landed org-scoped:
// one item, two partitions, addressed by spelling.
//
// ⛔ NOT repaired by widening `declaresOrgOverride`'s set —
// a predicate below the boundary consuming the URL spelling
// contract is what `metadata-url-spelling.ts`'s own header
// forbids ("folding happens at the boundary and only
// there"), and `meta-write-org-scope.ts`'s
// `ORG_OVERRIDABLE_TYPES` header pins that limit.
//
// Only the scope ARGUMENT is folded. The request `type`
// stays the raw segment, exactly as the REST doors leave
// it: the protocol boundary folds it itself, and two
// pre-folds would hide a drift between them from the
// protocol's own tests.
const organizationId = organizationIdForMetaWrite(
canonicalMetaUrlType(type), activeOrganizationId,
);
// [#10888] Server-stated face: this branch answers through
// `deps.errorFromThrown`, which carries the refusal's
// `issues[]` in `details` (see the `details.issues` pin in
Expand Down
Loading
Loading