Skip to content

Commit e13ede8

Browse files
hotlongclaude
andauthored
fix(rest): the /references door forwards the caller's organization, so the "Used by" panel stops clearing a delete an org-scoped item depends on (#15689)
* fix(rest): forward the caller's organization to the /references door, raw `GET /api/v1/meta/:type/:name/references` backs the admin "Used by" panel, whose empty case renders "Nothing in the metadata graph points at this item. Safe to delete." to an operator about to delete something. The door named no organization, so `findReferencesToMeta` swept the environment partition only and an org-scoped `view` pointing at the item was invisible — a false clearance on a destructive action (the ADR-0110 D3 harm this route's own 501 refusal exists to prevent). The door now resolves the memoised exec ctx and passes `ctx?.tenantId` RAW. Not pre-gated on `canonicalMetaUrlType(req.params.type)` the way the sibling `/meta` doors are: that type is the reference TARGET, while the organization is spent on the SOURCES, so gating on it would suppress the organization for exactly the `object` / `flow` / `app` deletes this is about. Raw is safe because `getMetaItems` applies `organizationIdForMetaRead` to its OWN request type since #14683 — the per-source-type decision is already the callee's, and `request.organizationId` has exactly one use inside `findReferencesToMeta`. Pins in `rest-server-meta-read-org-scope.test.ts` drive real routes over a real protocol: the org-scoped `view` is now found (red on the base commit), and the narrowness control proves a non-overridable SOURCE is still read env-wide with no phantom row resurrected. The `resolveExecCtx` census ledger moves 76 → 77 sites / 97 → 98 mentions, 23 → 24 locally caught. No new parameter, response field or contract surface: ADR-0131 D6/D7 retires this partition in v18, so nothing is built on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(docs): re-anchor the system-context census lines the /references door shifted `check:check-system-context-census --fix` output: the four `rest-server.ts` elevation-read anchors on the system-context page moved by the same +12 lines the door's comment added. Pure line rot, no row content changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(rest): name the filed finding (#15685) in the /references refusal pin The pin reads the refusal code through both of this route's envelopes because the two disagree; that disagreement is now a filed card rather than an unattributed observation in a comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 036b4ca commit e13ede8

5 files changed

Lines changed: 302 additions & 53 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
The admin "Used by" panel no longer clears a delete when the caller's own organization is using the item.
6+
7+
`GET /api/v1/meta/:type/:name/references` backs that panel, whose empty case reads "Nothing in the metadata graph points at this item. Safe to delete." — advice given to an operator about to delete something. The door supplied no organization, so the reference sweep read the environment partition only: an organization-scoped `view` (or `dashboard`, `report`, `translation`, `email_template`) pointing straight at the object being deleted was invisible, and the panel issued a false clearance. It now passes the caller's organization, and those references are returned.
8+
9+
The organization is passed RAW, deliberately, and that is the whole of the change — no new parameter, response field or contract surface. `req.params.type` is the reference TARGET, while the sweep spends the organization on the SOURCES it reads per type; `getMetaItems` applies the `allowOrgOverride` read gate to its own request type, so each source is scoped on its own registry flag. A non-overridable source (`object`, `flow`, `app`, …) is still read environment-wide and no pre-#6190 organization-scoped row is resurrected into a delete clearance. An anonymous or organization-less caller reads exactly what it read before, and no status code or response shape moves.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 sites**.
158158
|:--|:---|:---|:---|:---|
159159
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` |
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161-
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5016`, `:6430`, `:6678`, `:7109`, `:7302` |
161+
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5016`, `:6442`, `:6690`, `:7121`, `:7314` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163163
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |

‎packages/rest/src/execctx-consumer-census.test.ts‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,23 @@ describe('[#13160] §1 the production supplier fulfils with `undefined` rather t
309309
// ---------------------------------------------------------------------------
310310

311311
describe('[#13160] §2 the consumer surface, counted from the tree', () => {
312-
it('76 invocation sites, 97 mentions — the thread\'s two control numbers hold', () => {
312+
it('77 invocation sites, 98 mentions — the thread\'s two control numbers hold', () => {
313+
// [#13753, the `/references` half] 76 → 77 sites / 97 → 98 mentions.
314+
// `GET /meta/:type/:name/references` resolved NO identity, so the
315+
// reference sweep behind the admin "Used by" panel read the env
316+
// partition only and rendered "Nothing in the metadata graph points at
317+
// this item. Safe to delete." over an organization it never read. It
318+
// joins as a LOCALLY CAUGHT site in the continuation-line spelling, for
319+
// the same reason as its siblings: this door does not sit behind the
320+
// shared anonymous floor either.
321+
//
322+
// ⚠️ Here the two numbers moved by the SAME amount (+1 and +1), which
323+
// is the third pattern this block has recorded and is not a mistake:
324+
// the door's new comment states the memoised resolution in prose
325+
// WITHOUT naming the symbol, so the call is the only new mention. A
326+
// reader checking the +1/+2 shape of the entries below should read this
327+
// as the mention count tracking mentions, not as a lost site.
328+
//
313329
// [#13753] 75 → 76 sites / 95 → 97 mentions. `GET /meta/diagnostics`
314330
// resolved NO identity, so the Studio governance sweep could not state
315331
// which organization's partition it was reading and reported clean
@@ -361,11 +377,11 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
361377
// `enforceAuth` was measured NOT to be the repair). A mention count
362378
// that tracked the site count exactly would be measuring one thing
363379
// twice.
364-
expect(SITES.length).toBe(76);
365-
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(97);
380+
expect(SITES.length).toBe(77);
381+
expect(SOURCE.split('resolveExecCtx').length - 1).toBe(98);
366382
});
367383

368-
it('the split is 23 locally caught / 53 bare — NOT 16 / 53, which does not add to 76', () => {
384+
it('the split is 24 locally caught / 53 bare — NOT 16 / 53, which does not add to 77', () => {
369385
// 16 sites spell the catch on the invocation line; 4 more spell it on
370386
// the continuation line. A single-line grep sees 16 and the arithmetic
371387
// silently loses four sites.
@@ -375,12 +391,12 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
375391
// be the first of its kind and would break the structural claim below.
376392
const sameLine = CAUGHT.filter((s) => SOURCE.split('\n')[s.line - 1].includes('.catch('));
377393
expect(sameLine.length).toBe(16);
378-
expect(CAUGHT.length).toBe(23);
394+
expect(CAUGHT.length).toBe(24);
379395
expect(BARE.length).toBe(53);
380396
expect(CAUGHT.length + BARE.length).toBe(SITES.length);
381397
});
382398

383-
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 23 caught ones is', () => {
399+
it('⭐ every one of the 53 bare sites is guarded on the VERY NEXT LINE, and none of the 24 caught ones is', () => {
384400
// This inverts the reason the thread gave for doing the bare sites
385401
// first ("no local signal that a fault becomes an anonymous subject").
386402
// The bare sites are bare BECAUSE the shared anonymous floor is the

‎packages/rest/src/rest-server-meta-read-org-scope.test.ts‎

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,9 @@ function boot() {
341341
/** [#13753] The cross-type spec-validation sweep. */
342342
diagnostics: (query: Record<string, unknown> = {}) =>
343343
drive('GET', `${META}/diagnostics`, { query }),
344+
/** [#13753] The "Used by" sweep an operator reads before a delete. */
345+
references: (type: string, name: string) =>
346+
drive('GET', `${META}/:type/:name/references`, { params: { type, name } }),
344347
history: (type: string, name: string) =>
345348
drive('GET', `${META}/:type/:name/history`, { params: { type, name }, query: {} }),
346349
/** The fixture proof every history assertion below is gated on. */
@@ -786,3 +789,212 @@ describe('#13753 GET /meta/diagnostics states the org partition on the ?type= ar
786789
});
787790
});
788791
});
792+
793+
// ── [#13753] `GET /meta/:type/:name/references` ───────────────────────────
794+
//
795+
// `findReferencesToMeta` backs the admin "Used by" panel, whose empty case
796+
// reads — verbatim, objectui `metadata-admin/i18n.ts` — "Nothing in the
797+
// metadata graph points at this item. Safe to delete.", shown to an operator
798+
// about to delete something. The door named no organization, so the sweep read
799+
// the env partition only: an org-scoped `view` pointing at the object being
800+
// deleted was invisible and the panel issued a FALSE CLEARANCE. That is the
801+
// ADR-0110 D3 harm this route's own 501 refusal (#9326) was added to prevent,
802+
// answered by the door after the protocol had refused to answer it.
803+
//
804+
// ⭐ WHY THE DOOR PASSES THE TENANT **RAW** — and why the two cases below are a
805+
// PAIR rather than a case and a decoration. `req.params.type` is the TARGET;
806+
// the organization is spent on the SOURCES (`getMetaItems({ type:
807+
// matcher.fromType, … })` per `matcher`). Pre-gating on the target the way the
808+
// sibling `/meta` doors do would answer a question about the wrong type, and
809+
// on a non-overridable target (`object`, `flow`, `app` — the most common
810+
// delete there is) it would suppress the organization altogether and leave the
811+
// false clearance exactly where it was. Raw is nevertheless not an
812+
// unconditional tenant: since #14683 `getMetaItems` applies
813+
// `organizationIdForMetaRead` to its OWN `request.type`, so the per-SOURCE
814+
// decision is the callee's.
815+
//
816+
// ⇒ The first case pins that an OVERRIDABLE source is now found; the second
817+
// that a NON-OVERRIDABLE source is still read env-wide, phantom row and all.
818+
// One request, two source types, opposite scopes — which is the fact that
819+
// makes "raw" correct and that no assertion on either case alone can state.
820+
821+
/** An `object`-typed SOURCE: a lookup field naming `target`. */
822+
function objectReferencing(name: string, target: string): Record<string, unknown> {
823+
return {
824+
// [ADR-0090 D1] `sharingModel` is required at the write door; without
825+
// it this fixture fails on the WRITE and never reaches the read.
826+
name,
827+
label: MARKER,
828+
sharingModel: 'private',
829+
fields: { task_ref: { type: 'lookup', label: 'Task', reference: target } },
830+
};
831+
}
832+
833+
/** The item an operator is about to delete — what `bodyFor('view', …)` binds to. */
834+
const TARGET_OBJECT = 'task';
835+
836+
describe('#13753 GET /meta/:type/:name/references states the org partition', () => {
837+
let b: ReturnType<typeof boot>;
838+
beforeEach(() => { b = boot(); });
839+
840+
interface RefRow { type: string; name: string; label?: string; path: string; kind: string }
841+
const rowsOf = (body: any): RefRow[] => (body?.references ?? []) as RefRow[];
842+
const namesOf = (body: any, type: string) => rowsOf(body).filter((r) => r.type === type).map((r) => r.name);
843+
844+
it('⭐ THE CARD: an org-scoped `view` that references the object is FOUND', async () => {
845+
// `view` is `allowOrgOverride: true`, so this PUT lands in the org
846+
// partition — the fixture proof below is what makes the read
847+
// assertion a statement about scope rather than about the store.
848+
const written = await b.put(CACHED_ARM, 'task_list');
849+
expect(written.status, 'the view was never written').toBe(200);
850+
expect(
851+
storedRowsFor(b.rows, CACHED_ARM, 'task_list', ORG_A).length,
852+
'nothing landed in the org partition',
853+
).toBe(1);
854+
expect(
855+
storedRowsFor(b.rows, CACHED_ARM, 'task_list', null).length,
856+
'the write also landed env-wide — the partition is not real',
857+
).toBe(0);
858+
859+
const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT);
860+
expect(used.thrown, `the door threw: ${used.thrown?.message}`).toBeUndefined();
861+
expect(used.status).toBe(200);
862+
expect(
863+
namesOf(used.body, CACHED_ARM),
864+
'the sweep read a partition the caller does not live in, and the "Used by" panel '
865+
+ 'rendered "Safe to delete." over an org-scoped view that points straight at this object',
866+
).toContain('task_list');
867+
});
868+
869+
it('⛔ NARROWNESS CONTROL: a non-overridable SOURCE stays env-wide — no phantom row is resurrected', async () => {
870+
// The other half of the pair. `object` is `allowOrgOverride: false`, so
871+
// its runtime writes land ENV-WIDE even under an active org
872+
// (`organizationIdForMetaWrite`, #6190) — which is why the phantom has
873+
// to be planted directly. Rows like it exist in deployments that ran
874+
// before that ruling; boot hydration walks past them, so they are dead,
875+
// and a door that named the org for EVERY source type would read them
876+
// back into a destructive-action clearance — worse than an omission,
877+
// because a resurrected row reads as evidence.
878+
const written = await b.put(NON_OVERRIDABLE, 'env_orders');
879+
expect(written.status, 'the control never wrote').toBe(200);
880+
// Rewrite the stored document so this object actually REFERENCES the
881+
// target; the write door validates, so the shape is a real one.
882+
const envRow = storedRowsFor(b.rows, NON_OVERRIDABLE, 'env_orders', null);
883+
expect(envRow.length, 'a non-overridable write went org-scoped; the control controls nothing').toBe(1);
884+
envRow[0].metadata = JSON.stringify(objectReferencing('env_orders', TARGET_OBJECT));
885+
886+
b.rows.set(
887+
keyOf({ type: NON_OVERRIDABLE, name: 'phantom_orders', organization_id: ORG_A, state: 'active' }),
888+
{
889+
id: 'phantom_ref_1',
890+
type: NON_OVERRIDABLE,
891+
name: 'phantom_orders',
892+
organization_id: ORG_A,
893+
package_id: null,
894+
state: 'active',
895+
metadata: JSON.stringify(objectReferencing('phantom_orders', TARGET_OBJECT)),
896+
},
897+
);
898+
expect(
899+
storedRowsFor(b.rows, NON_OVERRIDABLE, 'phantom_orders', ORG_A).length,
900+
'the phantom was not planted; the control proves nothing',
901+
).toBe(1);
902+
903+
// ⭐ Same request, both source types — one org-scoped `view` beside the
904+
// two `object` rows, so the two scopes are read on ONE sweep.
905+
await b.put(CACHED_ARM, 'task_list');
906+
const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT);
907+
expect(used.status).toBe(200);
908+
909+
expect(
910+
namesOf(used.body, NON_OVERRIDABLE),
911+
'the env-wide `object` source was not swept at all — the exclusion below would be vacuous',
912+
).toContain('env_orders');
913+
expect(
914+
namesOf(used.body, NON_OVERRIDABLE),
915+
'the door named the organization for a type with no per-org read channel — the pre-#6190 '
916+
+ 'phantoms, resurrected on the read side inside a delete clearance',
917+
).not.toContain('phantom_orders');
918+
expect(
919+
namesOf(used.body, CACHED_ARM),
920+
'the overridable source lost its org scope on the same request — the gate is not per type',
921+
).toContain('task_list');
922+
});
923+
924+
describe('⛔ controls — the scope is STATED, and nothing else moves', () => {
925+
it('does not serve org A\'s source to org B on the same boot', async () => {
926+
await b.put(CACHED_ARM, 'task_list');
927+
expect(storedRowsFor(b.rows, CACHED_ARM, 'task_list', ORG_A).length).toBe(1);
928+
929+
b.as(ORG_B);
930+
const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT);
931+
expect(used.status).toBe(200);
932+
expect(namesOf(used.body, CACHED_ARM), 'org B was served org A\'s view').not.toContain('task_list');
933+
});
934+
935+
it('an org-LESS caller reads exactly what it read before', async () => {
936+
await b.put(CACHED_ARM, 'task_list');
937+
expect(storedRowsFor(b.rows, CACHED_ARM, 'task_list', ORG_A).length).toBe(1);
938+
939+
b.as(undefined);
940+
const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT);
941+
expect(used.status).toBe(200);
942+
expect(
943+
namesOf(used.body, CACHED_ARM),
944+
'an anonymous / org-less read moved — this door must not change for a caller that names no org',
945+
).not.toContain('task_list');
946+
});
947+
948+
it('and still serves ENV-WIDE sources to an org-scoped caller', async () => {
949+
// The other direction: naming the org must not narrow the answer
950+
// an org caller could already see.
951+
b.as(undefined);
952+
await b.put(CACHED_ARM, 'env_task_list');
953+
expect(storedRowsFor(b.rows, CACHED_ARM, 'env_task_list', null).length).toBe(1);
954+
955+
b.as(ORG_A);
956+
const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT);
957+
expect(used.status).toBe(200);
958+
expect(
959+
namesOf(used.body, CACHED_ARM),
960+
'an org session lost sight of an env-wide reference it could see before',
961+
).toContain('env_task_list');
962+
});
963+
964+
it('the response is the SAME wire shape — one `references` key, no new field', async () => {
965+
await b.put(CACHED_ARM, 'task_list');
966+
const used = await b.references(NON_OVERRIDABLE, TARGET_OBJECT);
967+
expect(used.status).toBe(200);
968+
expect(Object.keys(used.body ?? {})).toEqual(['references']);
969+
// The ROW shape too: a repair that added a scope discriminator per
970+
// row would satisfy every assertion above.
971+
expect(rowsOf(used.body).find((r) => r.name === 'task_list')).toEqual({
972+
type: CACHED_ARM, name: 'task_list', label: MARKER, path: 'object', kind: 'view object',
973+
});
974+
});
975+
976+
it('the #9327 unanswerable-target refusal keeps its code and status', async () => {
977+
// Asserted as `code` + `status` (ADR-0112) rather than as "it
978+
// threw": this route's refusals are the one thing on it an operator
979+
// reads as "the question was never asked", so a scope repair that
980+
// moved either would be moving the destructive-action clearance.
981+
//
982+
// ⚠️ The code is read through BOTH refusal dialects on purpose.
983+
// Measured on this boot, the two 501s this route can answer do not
984+
// agree: the missing-method branch hand-builds the ADR-0112 NESTED
985+
// `{ error: { code, message } }`, while the protocol-raised
986+
// unanswerable-target refusal reaches the wire as the FLAT
987+
// `{ error: 'Internal server error', code }` — the prescriptive
988+
// "ask the owning object instead" message scrubbed. That is a
989+
// finding of its own, filed as #15685; it is NOT this card's
990+
// subject, and reading both keeps this pin measuring the thing it
991+
// is about.
992+
const refused = await b.references('field', 'account.owner');
993+
const body = refused.body as any;
994+
const observed = refused.thrown
995+
? { status: refused.thrown.status, code: refused.thrown.code }
996+
: { status: refused.status, code: body?.error?.code ?? body?.code };
997+
expect(observed).toEqual({ status: 501, code: 'NOT_IMPLEMENTED' });
998+
});
999+
});
1000+
});

0 commit comments

Comments
 (0)