Skip to content

Commit 7743440

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20331-validate-view-container-name
2 parents 1bffac7 + b1cbd92 commit 7743440

14 files changed

Lines changed: 1518 additions & 37 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/rest": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
**Pending metadata drafts are now served only to a caller with an authoring capability — the check `GET /api/v1/meta/_drafts` already made.** A pending draft is unpublished authoring work. Until now, every door that reads one, other than `/meta/_drafts`, served it to any signed-in caller who could open the item. The draft access that the `previewDrafts` / `state` request declarations, ADR-0106 D4 and ADR-0037 described as admin-gated upstream is now gated.
7+
8+
Clause-②: no
9+
10+
- **The doors:** `GET /api/v1/meta/:type/:name?state=draft` and `?preview=draft`, `GET /api/v1/meta/:type?preview=draft`, and `POST /api/v1/analytics/dataset/query` with `previewDrafts: true` or `?preview=draft` on `RestServer`, plus the runtime dispatcher's `/meta` item and list `?preview=draft`.
11+
- **Who may read drafts:** a system context, or a caller holding `studio.access`, `setup.access` or `manage_metadata`. This is the same predicate `/meta/_drafts` asks, not a second rule.
12+
- **Everyone else gets the read as if the draft switch were absent.** They receive the published version, pruned for them as the plain read prunes it. For a name that has nothing published, they receive that door's own absence answer: `404` on the item read, `404 NOT_FOUND` for a dataset by name, and the item simply missing from a list. The answer is byte-identical to the plain read, so it does not reveal whether a draft exists. For example, `?state=draft` on an app with no pending draft answers such a caller with the published app, not `404 NO_DRAFT`. A dataset preview run by such a caller uses live rows, never a pending seed draft's rows.
13+
- **Unchanged:** callers with an authoring capability read exactly what they read before. Whoever may save an app reads its `?state=draft` whole, and everyone else pruned per caller. `/meta/_drafts` still answers `403` to a caller without the capability, because it lists drafts and has no published answer to fall back to. `/diff` and `/history` are not changed by this release.

‎content/docs/ui/apps.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,12 @@ them, left out as the single-app read leaves them out, on every layer, in the
374374
draft, and on both sides of a diff. These doors apply no per-deployment gate:
375375
an entry whose `requiresService` names a service this deployment lacks is part
376376
of the stored version, for every caller. The exemption lifts no whole refusal:
377-
an app an author may not open is refused on these doors too.
377+
an app an author may not open is refused on these doors too. Reading a
378+
*pending* draft at all — `?state=draft`, and the `?preview=draft` preview —
379+
needs an authoring capability (`studio.access`, `setup.access` or
380+
`manage_metadata`: the check `GET /api/v1/meta/_drafts` makes). A caller
381+
without one is answered as if the parameter were absent: the published app,
382+
or `404` for an app that has never been published.
378383

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

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

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@
4444
* The exemption is a CALLER property, honoured by these four doors and by
4545
* no other: the rendered plain read (`?preview=draft` included) and
4646
* `/published` still prune for an author.
47+
*
48+
* [#20338] The draft door serves the draft only to a caller who may read
49+
* drafts at all — the authoring capability `GET /meta/_drafts` asks
50+
* (`readsDrafts` below). Anyone else is never handed one: `?state=draft`
51+
* answers them what the plain read answers them, byte for byte.
4752
* - **`/history` and `/audit` serve events, never a body**: they refuse where
4853
* the plain read refuses the item whole, and otherwise serve the events.
4954
* - **`/references`** is declared exempt: it serves the identities of OTHER
@@ -164,19 +169,27 @@ interface Caller {
164169
* call a caller an author the save door refuses, or the reverse.
165170
*/
166171
savesApps: boolean;
172+
/**
173+
* [#20338] Does `GET /meta/_drafts` admit this caller? DECLARED here and
174+
* checked against that door below: every draft door asks the predicate it
175+
* asks, so a caller it refuses is never served a draft by `?state=draft`.
176+
*/
177+
readsDrafts: boolean;
167178
}
168179
const CALLERS: Record<'reader' | 'non-reader' | 'author' | 'anonymous', Caller> = {
169180
reader: {
170181
ctx: { userId: 'u_reader', systemPermissions: ['finance.access', 'payroll.access', 'studio.access'] },
171182
holdings: ['crm_admin'],
172183
readableFields: ['amount', 'secret_margin'],
173184
savesApps: false,
185+
readsDrafts: true,
174186
},
175187
'non-reader': {
176188
ctx: { userId: 'u_member', systemPermissions: [] },
177189
holdings: [],
178190
readableFields: ['amount'],
179191
savesApps: false,
192+
readsDrafts: false,
180193
},
181194
// An author the plain read serves only PART of `crm`: they may write
182195
// metadata, but hold neither `finance.access` nor `crm_admin`. And no
@@ -187,8 +200,9 @@ const CALLERS: Record<'reader' | 'non-reader' | 'author' | 'anonymous', Caller>
187200
holdings: [],
188201
readableFields: ['amount'],
189202
savesApps: true,
203+
readsDrafts: true,
190204
},
191-
anonymous: { holdings: [], readableFields: [], savesApps: false },
205+
anonymous: { holdings: [], readableFields: [], savesApps: false, readsDrafts: false },
192206
};
193207
type CallerName = keyof typeof CALLERS;
194208

@@ -269,6 +283,8 @@ function setup(who: CallerName | Caller) {
269283
}),
270284
// The save door's protocol call — reached only past its admission.
271285
saveMetaItem: vi.fn(async ({ type, name }: any) => ({ success: true, type: singular(type), name })),
286+
// [#20338] `GET /meta/_drafts` — reached only past its admission.
287+
listDrafts: vi.fn(async () => ({ items: [] })),
272288
findReferencesToMeta: vi.fn(async () => ({ references: [] })),
273289
findData: vi.fn().mockResolvedValue([]),
274290
};
@@ -311,6 +327,15 @@ async function save(rest: any, type: string, name: string, item: any) {
311327
return res;
312328
}
313329

330+
/** [#20338] `GET /meta/_drafts` — the door whose admission IS `readsDrafts`. */
331+
async function listDrafts(rest: any) {
332+
const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === `${META}/_drafts`);
333+
if (!route) throw new Error(`GET ${META}/_drafts is not registered`);
334+
const res = makeRes();
335+
await route.handler({ method: 'GET', path: `${META}/_drafts`, params: {}, query: {}, body: {}, headers: {} }, res);
336+
return res;
337+
}
338+
314339
/** The ADR-0112 minimum: the status and the machine code, never the prose. */
315340
const envelope = (res: any) => ({ status: res.statusCode, code: res.body?.code ?? res.body?.error?.code });
316341
const text = (res: any): string => JSON.stringify(res.body ?? null);
@@ -581,6 +606,24 @@ describe(`[#20156 · #20290] ruling ${AUTHOR_EXEMPTION.ruling} — the author ex
581606
}
582607
}
583608
});
609+
610+
it('[#20338] `readsDrafts` is exactly who `GET /meta/_drafts` admits — the predicate every draft door asks', async () => {
611+
for (const callerName of Object.keys(CALLERS) as CallerName[]) {
612+
const { rest, protocol } = setup(callerName);
613+
const res = await listDrafts(rest);
614+
if (CALLERS[callerName].readsDrafts) {
615+
expect(res.statusCode, callerName).toBe(200);
616+
expect(protocol.listDrafts, callerName).toHaveBeenCalledTimes(1);
617+
} else {
618+
expect(envelope(res), callerName).toEqual(
619+
CALLERS[callerName].ctx
620+
? { status: 403, code: 'FORBIDDEN' }
621+
: { status: 401, code: 'UNAUTHENTICATED' },
622+
);
623+
expect(protocol.listDrafts, callerName).not.toHaveBeenCalled();
624+
}
625+
}
626+
});
584627
});
585628

586629
describe(`[#20156] every alternate door answers what the plain read answers, or refuses — save the author exemption of ruling ${AUTHOR_EXEMPTION.ruling}`, () => {
@@ -601,11 +644,22 @@ describe(`[#20156] every alternate door answers what the plain read answers, or
601644
const res = await drive(rest, door.suffix, subject.type, subject.name, door.query);
602645
const stored = find(subject.type, subject.name);
603646
if (door.serves === 'draft' && CALLERS[callerName].ctx) {
647+
const asked = protocol.getMetaItem.mock.calls.map(([r]: any[]) => r?.state);
648+
if (!CALLERS[callerName].readsDrafts) {
649+
// [#20338] A caller who may not read drafts is
650+
// never handed one: the protocol is not asked for
651+
// the draft row, and the door answers what the
652+
// plain read answers them, byte for byte.
653+
expect(asked).not.toContain('draft');
654+
expect(envelope(res)).toEqual(envelope(plain));
655+
expect(res.body).toEqual(plain.body);
656+
return;
657+
}
604658
// [#20290] The cell measured the draft branch: the
605659
// protocol was asked for the draft row. (An anonymous
606660
// caller is refused by the `/meta` auth gate before
607661
// any read, on every door alike.)
608-
expect(protocol.getMetaItem.mock.calls.map(([r]: any[]) => r?.state)).toContain('draft');
662+
expect(asked).toContain('draft');
609663
}
610664

611665
if (exempt) {
@@ -759,6 +813,9 @@ describe('[#20156] edges', () => {
759813
holdings: [],
760814
readableFields: ['amount'],
761815
savesApps: false,
816+
// [#20338] No authoring capability either, so `?state=draft`
817+
// serves them the published app — pruned, as below.
818+
readsDrafts: false,
762819
};
763820
const { rest, protocol } = setup(presenter);
764821

‎packages/rest/src/meta-draft-read-author-exemption.test.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,13 @@ const CALLERS = {
5757
financeAuthor: { userId: 'u_finance_author', systemPermissions: ['manage_metadata', 'finance.access'] },
5858
/** The card's author: may save the app, but holds no `finance.access`. */
5959
author: { userId: 'u_author', systemPermissions: ['manage_metadata'] },
60-
/** May open the app, may not save it. */
61-
member: { userId: 'u_member', systemPermissions: [] },
60+
/**
61+
* May open the app and read drafts (a Studio builder), may not save it.
62+
* [#20338] A caller who may not read drafts at all is never served one —
63+
* they read the published app (`meta-draft-read-builder-gate.test.ts`) — so
64+
* the "may not save" control is a caller who may still read the draft.
65+
*/
66+
builder: { userId: 'u_builder', systemPermissions: ['studio.access'] },
6267
} as const;
6368
type CallerName = keyof typeof CALLERS;
6469

@@ -189,14 +194,14 @@ describe('[#20290] the draft read serves an app author the stored draft whole',
189194

190195
it('a caller who may not save the app reads the draft pruned per caller (the control) — no per-deployment gate either, as on /layers', async () => {
191196
const { call } = await boot();
192-
const res = await call('member', 'GET', '', 'atlas', { state: 'draft' });
197+
const res = await call('builder', 'GET', '', 'atlas', { state: 'draft' });
193198

194199
expect(res.statusCode).toBe(200);
195200
expect(res.body?.item?.label).toBe('Atlas (draft)');
196201
expect(navIds(res.body?.item)).toEqual(['nav_leads', 'nav_org_directory']);
197202
expect(JSON.stringify(res.body)).not.toContain('nav_finance');
198203
// The same per-caller answer the stored-version doors give them.
199-
const layers = await call('member', 'GET', '/layers', 'atlas');
204+
const layers = await call('builder', 'GET', '/layers', 'atlas');
200205
expect(navIds(layers.body?.effective)).toEqual(['nav_leads', 'nav_org_directory']);
201206
}, 60_000);
202207

0 commit comments

Comments
 (0)