Skip to content

Commit 92e6b2f

Browse files
committed
fix(rest): the app row of the stored-version doors waits on a decision; cite landing commits for two cards that no longer resolve
The refuse-on-partial app arm on /layers, ?layers=true and /diff drew a new permission boundary: it locked any author lacking one entry's permission, platform admins included, out of the designer's layered view. It is removed whole: those three doors serve the stored app exactly as before the gate, and the census pins those cells as a declared pending-decision exemption anchored to the card. Every other arm stands. The two citations the shared gate moved (the plural-spelling and folded-type cards) no longer resolve on the board; they now name the commits that landed them. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
1 parent a733beb commit 92e6b2f

4 files changed

Lines changed: 207 additions & 149 deletions

File tree

‎.changeset/20156-alternate-door-read-gates.md‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@
22
"@objectstack/rest": patch
33
---
44

5-
**Every read door beside `GET /api/v1/meta/:type/:name` now applies the same per-caller read gate as the plain read.** The plain read withholds a document per caller in four ways: the documentation audience on `doc` and `book` (a `{ permissionSet }`-gated doc or book is `403 PERMISSION_DENIED` to a non-holder, `401 UNAUTHENTICATED` to an anonymous caller), the app navigation filter on `app` (an app whose `requiredPermissions` the caller lacks is `403`, an unpublished app is `404` to a non-builder, and entries gated by `requiredPermissions` or the docs audience are left out), and the field mask on `object` schemas. The doors beside it served the same stored document with none of those gates:
5+
**The read doors beside `GET /api/v1/meta/:type/:name` now apply the plain read's per-caller gates to docs, books and object schemas.** The plain read withholds a document per caller in several ways. On `doc` and `book` it applies the documentation audience: a `{ permissionSet }`-gated doc or book is `403 PERMISSION_DENIED` to a non-holder and `401 UNAUTHENTICATED` to an anonymous caller. On `app` it applies the navigation filter: an app whose `requiredPermissions` the caller lacks is `403`, an unpublished app is `404` to a non-builder, and gated entries are left out. On `object` schemas it applies the field mask. It also applies the optional-service widget gate on `dashboard`. The doors beside it served the same stored document with none of those gates:
66

77
- `GET …/:name/layers`, and the deprecated `GET …/:name?layers=true`, served every layer. This exposed a gated doc's or book's body to any signed-in member. Through `?layers=true`, which sits on the route anonymous callers may reach for public docs, it also exposed any doc or book to a caller who was not signed in.
8-
- `GET …/:name/published` served a gated doc's or book's body, a gated or unpublished app whole, and an object schema's unreadable fields.
9-
- `GET …/:name/diff` served both compared versions' values, including a doc's content, an app's navigation and an object's fields.
10-
- `GET …/:name/history` and `GET …/:name/audit` served the change log and audit trail of an item the caller may not open.
8+
- `GET …/:name/published` served a gated doc's or book's body, a gated or unpublished app whole, a dashboard's widgets bound to an optional service this deployment lacks, and an object schema's unreadable fields.
9+
- `GET …/:name/diff` served both compared versions' values, including a doc's content and an object's fields.
10+
- `GET …/:name/history` and `GET …/:name/audit` served the change log and audit trail of a doc, book or app the caller may not open.
1111

1212
What each door answers now:
1313

14-
- `/published` answers exactly what the plain read answers the same caller: the same refusal, or the same pruned or masked document.
15-
- `/layers`, `?layers=true` and `/diff` serve stored versions. Where the plain read refuses the item, they refuse it with the same status and code. Where the plain read would serve a caller only part of an app, they answer `403 PERMISSION_DENIED` instead of a pruned version, because Studio's designer saves back what it loads, and a pruned version saved back deletes the entries withheld from that caller. They mask object fields as the plain read does. Per-deployment gates are not applied to them: a dashboard widget or app entry whose optional service is off in this deployment is still shown, because it is stored.
16-
- `/history` and `/audit` answer the plain read's refusal when the plain read refuses the item whole. Otherwise they serve the events, which carry no document body.
17-
- `/diff` of a `doc`, `book` or `app` with nothing behind the name answers `404 RESOURCE_NOT_FOUND`, as the plain read does.
14+
- `/published` answers exactly what the plain read answers the same caller, for every type: the same refusal, or the same pruned or masked document. That includes the dashboard widget gate: a widget bound to an optional service this deployment does not register is left out of `/published`, as it is from the plain read.
15+
- `/layers`, `?layers=true` and `/diff` refuse a `doc` or `book` the plain read refuses, with the same status and code, and mask object fields as the plain read does. `/diff` of a `doc` or `book` with nothing behind the name answers `404 RESOURCE_NOT_FOUND`, as the plain read does. They do not apply the dashboard widget gate or any other per-deployment gate: they show the stored version, which is what an author edits.
16+
- **For `app`, `/layers`, `?layers=true` and `/diff` are unchanged in this release.** They still serve the stored app, including navigation entries the plain read withholds from the caller, to every signed-in caller. What they should answer is pending a decision, because both ways of matching the plain read restrict authors. Pruning the stored app would delete the withheld entries when Studio's designer saves back what it loaded. Refusing it would lock out any author who lacks one entry's permission, platform admins included.
17+
- `/history` and `/audit` answer the plain read's refusal when the plain read refuses the doc, book or app whole. Otherwise they serve the events, which carry no document body.
1818

19-
Nothing changes for a caller the plain read serves in full: every door answers as before. Types no per-caller gate judges (`view`, `flow`, and others) are untouched on every door. A client reading these doors as a caller the plain read restricts now receives the plain read's answer. That includes an integration reading `?layers=true` anonymously. To read a gated doc, hold the permission set its book names. To use the layered view or the diff of an app, hold every permission its entries require.
19+
Types no per-caller gate judges, such as `view` and `flow`, are unchanged on every door. `dashboard` is unchanged on every door except `/published`. A caller the plain read serves in full gets the same answers as before. A client reading these doors as a caller the plain read restricts now receives the plain read's answer, including an integration reading `?layers=true` anonymously. To read a gated doc or book through any of these doors, hold the permission set its book names.

‎content/docs/ui/apps.mdx‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -356,14 +356,16 @@ collapses; an area *authored* empty is passed through untouched, because
356356
filtering reports what the caller may not see rather than tidying the metadata.
357357

358358
The single-app reads — `GET /api/v1/meta/app/<name>` and its `/published`
359-
twin — serve that filtered body. The doors that serve an app's *stored*
360-
versions for authoring — the layered view (`/layers`) and `/diff` — never serve
361-
an entry `requiredPermissions` withholds from the caller either: a caller who
362-
may see only part of the app is refused them (`403 PERMISSION_DENIED`), because
363-
the designer saves back what it loads and a pruned copy would delete the
364-
withheld entries. A `requiresService` entry, by contrast, is shown there as
365-
stored: an optional service being off in this deployment withholds nothing from
366-
the caller, and the entry is still part of what the author edits.
359+
twin — serve that filtered body, and `/history` and `/audit` refuse an app the
360+
caller may not open. The doors that serve an app's *stored* versions for
361+
authoring — the layered view (`/layers`, and the deprecated `?layers=true`) and
362+
`/diff` — do **not** apply this filter yet: they serve the stored app, every
363+
entry included, to any signed-in caller. What they should answer is pending a
364+
decision. Pruning a stored app the designer saves back would delete the
365+
withheld entries, and refusing it would lock out any author who lacks one
366+
entry's permission, platform admins included. Until that is decided, an entry
367+
gated by `requiredPermissions` is absent from the app's served body but not
368+
from these authoring views.
367369

368370
`visible` did **not** move server-side with them, and that asymmetry is
369371
deliberate: CEL is evaluated in the browser because server-side evaluation needs

‎packages/rest/src/meta-alternate-door-read-gates.test.ts‎

Lines changed: 92 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,21 @@
1919
* read serves: it answers exactly what the plain read answers — the same
2020
* refusal, or the same pruned or masked document.
2121
* - **`/layers`, `?layers=true` and `/diff` serve STORED versions** — the
22-
* layers side by side, or two versions compared. They carry only the
23-
* per-caller gates, and where the plain read would serve this caller a
24-
* PART of an app (entries withheld) they refuse it `403 PERMISSION_DENIED`
25-
* rather than hand back a pruned stored version: Studio's designer loads the
26-
* layered view and saves what it loaded, so a pruned version saved back is
27-
* the withheld entries silently deleted. The object mask is the one per-caller
28-
* arm they still APPLY, because ADR-0106 D4 exempts every caller who may
29-
* write a schema from it — the masked view never reaches a writer.
22+
* layers side by side, or two versions compared. They carry the per-caller
23+
* gates: the docs audience refuses what the plain read refuses, and the
24+
* object mask projects as the plain read does (ADR-0106 D4 exempts every
25+
* caller who may write a schema, so a masked version never reaches a
26+
* writer).
27+
*
28+
* ⚠️ **Their `app` row is a DECLARED PENDING-DECISION EXEMPTION** (decision
29+
* anchor #20156, `PENDING_DECISION` below). Both ways of pulling it to the
30+
* plain read's answer draw a new permission boundary, which is the
31+
* maintainer's to rule: a PRUNED stored app is deleted entries once Studio's
32+
* designer saves back what it loaded, and a REFUSED one locks out an author
33+
* — a platform admin included — who lacks one entry's permission. So those
34+
* cells answer what they answered before this card (the stored app, to every
35+
* signed-in caller), and this file pins exactly that, under the anchor, so
36+
* the exemption stays loud and cannot widen until it is ruled.
3037
* - **`/history` and `/audit` serve events, never a body**: they refuse where
3138
* the plain read refuses the item whole, and otherwise serve the events.
3239
* - **`/references`** is declared exempt: it serves the identities of OTHER
@@ -286,6 +293,28 @@ const DOORS: Record<string, Door> = {
286293
},
287294
};
288295

296+
/**
297+
* ⚠️ THE DECLARED PENDING-DECISION EXEMPTION — decision anchor #20156.
298+
*
299+
* The `app` row of the stored-version doors. Pulling it to the plain read's
300+
* answer draws a NEW permission boundary either way (a pruned stored app is
301+
* deleted entries once the designer saves back; a refused one locks out an
302+
* author, a platform admin included, who lacks one entry's permission), and a
303+
* new boundary is the maintainer's to rule. Until it is, these cells answer
304+
* exactly what they answered before the gate existed — the stored app, to
305+
* every signed-in caller — and the census pins THAT, under this anchor: when
306+
* the ruling lands, these cells go red and are rewritten deliberately, never
307+
* drifted into. The row may not widen: a test below holds it to exactly the
308+
* `app` type on exactly the three stored-version doors.
309+
*/
310+
const PENDING_DECISION = Object.freeze({
311+
anchor: '#20156',
312+
type: 'app',
313+
doors: Object.freeze(['?layers=true', '/layers', '/diff']),
314+
});
315+
const isPendingDecision = (type: string, doorName: string): boolean =>
316+
type === PENDING_DECISION.type && PENDING_DECISION.doors.includes(doorName);
317+
289318
// ── The subjects, and what the plain read answers each caller ────────────────
290319

291320
/**
@@ -405,19 +434,50 @@ describe('[#20156] the plain read answers what the census declares', () => {
405434
}
406435
});
407436

408-
describe('[#20156] every alternate door answers what the plain read answers, or refuses', () => {
437+
describe('[#20156] the pending-decision exemption is declared, anchored, and no wider than the app row of the stored-version doors', () => {
438+
it(`exactly the \`app\` type on exactly the stored-version doors, anchored to ${PENDING_DECISION.anchor}`, () => {
439+
const storedDoors = Object.entries(DOORS).filter(([, d]) => d.kind === 'stored').map(([n]) => n).sort();
440+
expect([...PENDING_DECISION.doors].sort()).toEqual(storedDoors);
441+
expect(PENDING_DECISION.type).toBe('app');
442+
expect(PENDING_DECISION.anchor).toBe('#20156');
443+
});
444+
});
445+
446+
describe('[#20156] every alternate door answers what the plain read answers, or refuses — save the declared pending-decision row', () => {
409447
for (const [doorName, door] of Object.entries(DOORS)) {
410448
if (door.kind === 'exempt') continue;
411449
describe(doorName, () => {
412450
for (const subject of SUBJECTS) {
413451
for (const callerName of Object.keys(CALLERS) as CallerName[]) {
414452
const want = subject.plain[callerName];
415-
it(`${subject.type}/${subject.name} × ${callerName}`, async () => {
416-
const { rest } = setup(callerName);
453+
const pending = isPendingDecision(subject.type, doorName) && CALLERS[callerName].ctx !== undefined;
454+
const title = `${subject.type}/${subject.name} × ${callerName}`
455+
+ (pending ? ` — PENDING-DECISION ${PENDING_DECISION.anchor}: the stored app, as before the gate` : '');
456+
it(title, async () => {
457+
const { rest, protocol } = setup(callerName);
417458
const plain = await drive(rest, '', subject.type, subject.name);
459+
protocol.getMetaItem.mockClear();
418460
const res = await drive(rest, door.suffix, subject.type, subject.name, door.query);
419461
const stored = find(subject.type, subject.name);
420462

463+
if (pending) {
464+
// The pre-gate answer, pinned under the anchor. LOUD by
465+
// construction: the entries the plain read withholds from
466+
// a non-reader ARE in this body — the exposure the
467+
// decision waits on — and no gate input is read.
468+
expect(res.statusCode).toBe(200);
469+
for (const s of subject.secrets) expect(text(res)).toContain(s);
470+
if (door.suffix === '/diff') {
471+
expect(res.body).toEqual(await protocol.diffMetaItem.mock.results.at(-1)?.value);
472+
expect(protocol.getMetaItem).not.toHaveBeenCalled();
473+
} else {
474+
for (const layer of ['code', 'overlay', 'effective']) {
475+
expect(res.body?.[layer]).toEqual(stored);
476+
}
477+
}
478+
return;
479+
}
480+
421481
// A refusal of the item WHOLE is the plain read's refusal
422482
// on every door: same status, same code, nothing withheld.
423483
if (want.kind === 'refused') {
@@ -444,14 +504,9 @@ describe('[#20156] every alternate door answers what the plain read answers, or
444504
return;
445505
}
446506

447-
// door.kind === 'stored'
448-
if (want.kind === 'pruned') {
449-
// A part of the app is withheld from this caller, and a
450-
// stored version is served whole or not at all.
451-
expect(envelope(res)).toEqual({ status: 403, code: 'PERMISSION_DENIED' });
452-
for (const s of subject.secrets) expect(text(res)).not.toContain(s);
453-
return;
454-
}
507+
// door.kind === 'stored' — the `pruned` answer belongs to
508+
// the `app` row alone, which is pending above.
509+
expect(want.kind).not.toBe('pruned');
455510
expect(res.statusCode).toBe(200);
456511
if (want.kind === 'masked') {
457512
for (const s of subject.secrets) expect(text(res)).not.toContain(s);
@@ -497,10 +552,11 @@ describe('[#20156] edges', () => {
497552
expect(text(res)).not.toContain(BOOK_SECRET);
498553
});
499554

500-
it('a gate-input FAULT on a stored-version door is the fault, never a 403 and never the body', async () => {
501-
// The app's docs-audience entry arm reads the books; that read fails.
502-
// Pruning every `doc` entry would read here as "part of this app is
503-
// withheld from you" — a 403 telling a holder they hold nothing.
555+
it('a gate-input FAULT is the fault on every door — never a 403, never the body [#20129 carried to the doors]', async () => {
556+
// The docs audience reads the books; that read fails. Read as `[]` it
557+
// would grant (no gated book anywhere), so every door hands the fault
558+
// to the route instead — the plain read's own answer since #20129,
559+
// and a holder is not told they hold nothing.
504560
const { rest, protocol } = setup('reader');
505561
const listRead = protocol.getMetaItems.getMockImplementation();
506562
protocol.getMetaItems.mockImplementation(async (request: any) => {
@@ -509,17 +565,21 @@ describe('[#20156] edges', () => {
509565
}
510566
return listRead(request);
511567
});
512-
for (const suffix of ['/layers', '/diff']) {
513-
const res = await drive(rest, suffix, 'app', 'crm');
514-
expect(envelope(res), suffix).toEqual({ status: 503, code: 'SERVICE_UNAVAILABLE' });
515-
expect(text(res), suffix).not.toContain('nav_finance_ledger');
568+
const plainDoc = await drive(rest, '', 'doc', 'crm_admin_runbook');
569+
expect(envelope(plainDoc)).toEqual({ status: 503, code: 'SERVICE_UNAVAILABLE' });
570+
for (const [doorName, door] of Object.entries(DOORS)) {
571+
if (door.kind === 'exempt') continue;
572+
const res = await drive(rest, door.suffix, 'doc', 'crm_admin_runbook', door.query);
573+
expect(envelope(res), doorName).toEqual(envelope(plainDoc));
574+
expect(text(res), doorName).not.toContain(DOC_SECRET);
516575
}
517-
// The single-document doors keep the plain read's answer to that fault:
518-
// the `doc` entries left out, the rest of the navigation served.
519-
const plain = await drive(rest, '', 'app', 'crm');
576+
// An app's docs-audience entry arm takes the plain read's composite
577+
// answer on the single-document door: the `doc` entries left out, the
578+
// rest of the navigation served.
579+
const plainApp = await drive(rest, '', 'app', 'crm');
520580
const published = await drive(rest, '/published', 'app', 'crm');
521-
expect(plain.statusCode).toBe(200);
522-
expect(navIds(published.body)).toEqual(navIds(plainItem(plain)));
581+
expect(plainApp.statusCode).toBe(200);
582+
expect(navIds(published.body)).toEqual(navIds(plainItem(plainApp)));
523583
expect(navIds(published.body)).not.toContain('nav_admin_runbook');
524584
});
525585

0 commit comments

Comments
 (0)