Skip to content

Commit 4c213c2

Browse files
os-zhuangclaude
andauthored
feat(security): master-detail controlled-by-parent permissions + verify topological synthesis (ADR-0055) (#2049)
* docs(adr): ADR-0055 — permission model evolves by gap-closure, not rewrite (Proposed) Records the architecture decision that the authorization model (already Salesforce-shaped: permission sets + FLS + predicate RLS + ownership + sharing rules + hierarchy) should close enumerable gaps via ADR-0049 enforce-or-remove and ADR-0054 prove-it-runs, against a mainstream conformance checklist — NOT a rewrite. Headline gap: master-detail `controlled_by_parent` is declared (OWDModel) yet has zero runtime consumers, isn't reachable through the object's `sharingModel` enum, and the RLS compiler is relationship-blind. Includes a conformance matrix and per-gap implement/remove/not-do verdicts. Status: Proposed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx * docs(adr): rewrite ADR-0055 as the concrete master-detail controlled-by-parent design Refocus from a high-level "gap-closure not rewrite" principle to the concrete landing plan for master-detail permission inheritance — the substance an ADR should carry. Grounded in an engine investigation: - Read: auto-derive the user's accessible master-id set and constrain the detail's master FK via the EXISTING pre-resolved-membership mechanism (ExecutionContext.rlsMembership + the compiler's `field IN (current_user.<key>)` form) — zero RLS-compiler changes. Trade-off table vs subquery-join (compiler has no subquery support) and materialized column (dual-write/stale). - Write: extend the #1994 by-id-write pre-image check to require master EDIT access. - Spec: add controlled_by_parent to the authorable sharingModel enum (fixes the OWDModel/sharingModel inconsistency); detail must declare one required master_detail field. - Proof (ADR-0054): a dogfood RLS proof; prerequisite is related-record topological synthesis in @objectstack/verify (P0). Honest limits: id-set size ceiling, single-level chains in v1. Status: Proposed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx * feat(verify): related-record topological synthesis (ADR-0055 P0) deriveCrudCases no longer skips objects with required relations. It builds the object dependency graph from required relational fields, topologically orders it (targets before dependents), and emits `relationalRefs` the runner fills with real ids — so relationship-dense objects (the core of real apps) are verified, not skipped. Both runners (runCrudVerification, runRlsProofs) thread a created-id registry via fillRelationalRefs. Honest `blocked` verdicts remain for the genuinely unsatisfiable: required relation to an external/missing target, required-reference cycle (incl. required self-reference), and cascade (target itself blocked). Optional relations never block — filled best-effort, else left null. This is the prerequisite harness for the master-detail controlled-by-parent RLS proof (ADR-0055 P2), and independently widens `objectstack verify` coverage. Tests: 10 unit cases (ordering, optional/required, external/missing, cascade, cycle, self-ref, id threading). Full dogfood suite green (69 tests); rls cross-owner counts unchanged on example-crm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx * feat(security,spec): master-detail controlled-by-parent permissions (ADR-0055 P1+P2) Implements derived master-detail access — a detail object's read/write scope is derived from its master record. - spec: `controlled_by_parent` added to the authorable `object.sharingModel` enum (converges with OWDModel). - security (read): for a controlled_by_parent object, resolve the master records the user can read (reusing the master's own RLS via computeRlsFilter, run under a system context — no middleware re-entry) and AND-inject `masterFK IN (accessible master ids)` onto the read AST. Empty set ⇒ fail closed. Zero RLS-compiler changes (builds the $in filter directly). - security (write): extend the by-id-write path — a detail insert/update/delete requires CRUD edit on the master AND the master row visible under the master's write RLS. Closes the #1994-class by-id hole for derived access (the detail carries no authored RLS of its own). - proof (ADR-0054): dogfood RLS proof — a member who can't read the master can neither read nor by-id-write the detail, and isn't over-blocked on a master they own. Bound in the liveness ledger on object.sharingModel. Five high-risk classes now CI-enforced: field types, RLS, controlled-by-parent, analytics, flow nodes. Honest v1 limits documented in ADR-0055 (id-set ceiling, single-level chains). Full dogfood suite green (73 tests); liveness gate green; 18 registry tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx * chore: ADR-0055 Accepted + changeset; fix pre-existing app-showcase typecheck - ADR-0055 status → Accepted (implemented in this PR, P0–P2). - changeset for the controlled-by-parent feature (spec/plugin-security/verify minor). - fix(app-showcase): task-triage userFilters tabs were missing the now-required pinned/isDefault/visible fields (a pre-existing main breakage from #2048 that reddened TypeScript Type Check on every PR) — examples typecheck now green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx * feat(showcase): controlled-by-parent invoice lines + HTTP verification (ADR-0055) Adds the canonical master-detail scenario to the showcase: `showcase_invoice_line` now declares `sharingModel: 'controlled_by_parent'`, so a line's access is derived from its parent `showcase_invoice` (a line is meaningless apart from its invoice). Admin/default single-tenant behavior is unchanged (admin sees all invoices → all lines); the showcase's own 20 tests still pass. Verification (real HTTP stack — the boundary is server-side; no browser needed): a dogfood proof boots the REAL showcase app with a member permission set carrying an owner policy on the invoice, then asserts a member who cannot read an admin-owned invoice can neither read nor by-id-write its lines, but can read/edit lines under an invoice they own. 4/4 green; full dogfood suite 77 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bbdecaa commit 4c213c2

18 files changed

Lines changed: 965 additions & 41 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
"@objectstack/spec": minor
4+
"@objectstack/verify": minor
5+
---
6+
7+
Master-detail "controlled by parent" permissions (ADR-0055).
8+
9+
A detail object can now declare `sharingModel: 'controlled_by_parent'`: its read/write access is derived from its master record, with no authored RLS.
10+
11+
- `@objectstack/spec`: `controlled_by_parent` added to the authorable `object.sharingModel` enum.
12+
- `@objectstack/plugin-security`: reads inject `masterFK IN (accessible master ids)` (resolved from the master's own RLS, reusing the existing filter machinery — zero RLS-compiler changes); by-id writes (insert/update/delete) to a detail now require edit access to its master, closing the #1994-class by-id hole for derived access.
13+
- `@objectstack/verify`: related-record **topological synthesis**`deriveCrudCases` no longer skips objects with required relations; it builds the object dependency graph, orders it topologically, and threads real target ids, so relationship-dense objects (and the master-detail RLS proof) are verifiable. Honest `blocked` verdicts remain for required-reference cycles and external/missing targets.
14+
15+
v1 limits (per ADR-0055): the accessible-master id set is unbounded (large-tenant scale is a documented future limit), and master-detail chains are single-level (not transitively traversed).
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# ADR-0055: Master-detail "controlled by parent" permissions — derived access via pre-resolved master-id membership
2+
3+
**Status**: Accepted (2026-06-19) — implemented in this PR (P0–P2)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (prove-it-runs)
6+
**Surfaced by**: an audit of master-detail permission semantics — `OWDModel.controlled_by_parent` is **declared but unenforced** (zero runtime consumers; not reachable through the object's `sharingModel` enum; the RLS compiler is relationship-blind). This is *false compliance* (ADR-0049) and *unproven liveness* (ADR-0054).
7+
8+
> **Framing, not the thesis.** The authorization model is already the mainstream (Salesforce-shaped) model — permission sets + FLS + predicate RLS + ownership + sharing rules + hierarchy. So this is **gap-closure, not a rewrite**: we close `controlled_by_parent` with the existing engine and gates. This ADR is the *concrete landing plan* for that one capability; cleanup items it touches (the `OWDModel``sharingModel` inconsistency, removing dead `contextVariables`) are PR-level tasks, not decisions, and are noted, not belaboured.
9+
10+
---
11+
12+
## TL;DR
13+
14+
A **master-detail detail** record today is access-controlled entirely on its own fields — its master record's access is **not** inherited. Salesforce's "Controlled by Parent" (detail visibility/edit derived from the master) is declared in the spec yet does nothing.
15+
16+
**Decision.** Implement `controlled_by_parent` by **auto-deriving the set of master records the user can access and constraining the detail's master-FK to that set** — reusing the engine's existing pre-resolved-membership mechanism (`ExecutionContext.rlsMembership` + the compiler's `field IN (current_user.<key>)` form), with **zero compiler changes**. Reads inject `masterFK IN (accessible_master_ids)`; by-id writes extend the #1994 pre-image check to require master **edit** access. The guarantee is proven by a `@objectstack/dogfood` RLS proof (ADR-0054), which depends on the verifier's related-record topological synthesis landing first.
17+
18+
---
19+
20+
## Context — the mechanisms this builds on (verified)
21+
22+
1. **Read-path RLS injection.** `security-plugin.ts:481-495` AND-s an RLS filter into the query AST (`opCtx.ast.where = { $and: [where, rlsFilter] }`) before the driver runs. The builder `computeRlsFilter` (`security-plugin.ts:753-788`) is **async** and shared by the engine find-path and the analytics raw-SQL path (`getReadFilter`).
23+
2. **Pre-resolved membership (§7.3.1) already exists.** The RLS compiler recognizes `field IN (current_user.<key>)` and resolves `<key>` against `ExecutionContext.rlsMembership` — "the runtime resolves set-membership that would otherwise need a subquery … and stages each set here under a stable key" (`execution-context.zod.ts:74-91`; merge at `rls-compiler.ts:79-99`). **This is the seam controlled_by_parent plugs into — no new compiler form.**
24+
3. **By-id write pre-image check (#1994).** `security-plugin.ts:341-400` already re-reads the target row under the write-op RLS filter before an update/delete and denies if invisible. This is the exact hook to extend with a master-access check.
25+
4. **`sharingModel` enforcement seam.** `sharing-service.ts:53-62` reads `object.sharingModel`; `buildReadFilter` (`:101-143`) gates on it (`effectiveSharingModel(schema) !== 'private'`). A `controlled_by_parent` baseline plugs in here / in the security middleware.
26+
5. **Master-detail storage.** A `master_detail` field's **key is the FK column**; its `reference` (`field.zod.ts:386-400`) names the master object. Given a detail row, the master id is `row[masterFieldKey]`.
27+
6. **Spec inconsistency (to fix either way).** `OWDModel` (`sharing.zod.ts`) includes `controlled_by_parent`; the object's authorable `sharingModel` (`object.zod.ts`) is a different enum `['private','read','read_write','full']` that omits it.
28+
29+
## Decision
30+
31+
### 1. Spec contract
32+
33+
- Add `controlled_by_parent` to the **authorable** `object.sharingModel` enum (converging it with `OWDModel`; resolves the inconsistency).
34+
- An object with `sharingModel: 'controlled_by_parent'` **must declare exactly one required `master_detail` field**; its `reference` identifies the master object and its field key is the master FK. Validation error otherwise (fail closed — an unsatisfiable "controlled by parent" must not silently fall open).
35+
- The author writes **no RLS policy** for this — "controlled by parent" is *derived automatically* from the relationship (Salesforce-like OWD), which is the whole point.
36+
37+
### 2. Read mechanism — pre-resolved accessible-master-id set (chosen)
38+
39+
For a `controlled_by_parent` object, the security layer, per request:
40+
1. resolves the **master object's** read filter for this user (`computeRlsFilter` on the master — the same machinery, reused), runs it to get the accessible master ids, and
41+
2. stages them in `ExecutionContext.rlsMembership` under a per-relationship key (e.g. `cbp_<object>_<field>`), then
42+
3. the detail's derived policy `"<masterFieldKey> IN (current_user.cbp_<object>_<field>)"` compiles via the **existing IN-form** and AND-s onto the read (step 1 in Context). An **empty** set fails closed (the compiler already returns `null` → deny) — correct: no accessible master ⇒ no detail.
43+
44+
This composes with the detail's own tenant/owner RLS (all AND-ed) and flows to analytics via `getReadFilter` unchanged.
45+
46+
**Why this mechanism (trade-offs):**
47+
48+
| Option | Verdict | Why |
49+
|---|---|---|
50+
| (a) query-time subquery join (`masterFK IN (SELECT id FROM master WHERE …)`) | ✗ rejected | the RLS compiler **deliberately has no subquery support** (`rls-compiler.ts:129-138`); the query AST has no EXISTS/sub-select form. Would require extending both. |
51+
| **(b) pre-resolved accessible-master-id set** |**chosen** | reuses the `rlsMembership` + IN-form path with **zero compiler changes**; resolution is one async pre-query per request, composes with existing RLS, reaches analytics. |
52+
| (c) materialized accessible-ids column | ✗ rejected | dual-write maintenance; goes stale the moment access rules/shares change mid-session — a correctness hazard for a security primitive. |
53+
54+
### 3. Write mechanism — extend the #1994 pre-image check
55+
56+
In the pre-image block (`security-plugin.ts:341-400`), for a `controlled_by_parent` detail `update`/`delete`/`create`:
57+
- resolve the target's master id (`row[masterFieldKey]`; for `create`, the master id in the incoming body), and
58+
- re-read the master under its **edit** write-filter (`findOne(master, { where: { $and: [{id: masterId}, masterWriteFilter] } })`); a `null` result ⇒ deny.
59+
- **Rule:** editing/deleting/creating a detail requires **edit** access to its master (Salesforce master-detail semantics). Reading a detail requires **read** access to its master (§2).
60+
61+
This reuses the existing re-read-and-deny pattern; no new enforcement layer.
62+
63+
### 4. Proof (ADR-0054)
64+
65+
A `@objectstack/dogfood` RLS proof, bound in the liveness ledger on `object.sharingModel` (and/or the master-detail field): a member who **cannot read master M** can **neither read nor by-id-write a detail D under M**, and **can** once granted master access (red/green, revert-provable). This is the runtime guard that flips `controlled_by_parent` from declared to *live*.
66+
67+
**Prerequisite:** the proof must create a master + a detail under it. Today `@objectstack/verify`'s `deriveCrudCases` **skips objects with required relations**. So the **related-record topological synthesis** capability (build the object dependency graph, synthesize in topo order threading real ids) lands **first** — it is P0 here.
68+
69+
## Consequences
70+
71+
- **Positive.** Master-detail finally carries permission inheritance (not just cascade/expand); a declared-but-dead OWD value becomes enforced with a permanent runtime guard; zero RLS-compiler changes (lowest-risk path through a security-critical subsystem); composes with tenant/owner/role RLS and analytics; the `OWDModel``sharingModel` inconsistency is resolved.
72+
- **Negative / limits (honest).**
73+
- **Set-size ceiling.** A user with very many accessible masters produces a large `IN (...)`. Acceptable for typical cardinalities; **large-tenant scale (≫ thousands of masters) is a known limit** — a future share-table/join mechanism would replace the id list. v1 documents this, not solves it.
74+
- **Per-request resolution cost.** One extra master-id query per controlled_by_parent object per request (cached within the request).
75+
- **Single-level only in v1.** Nested master-detail chains (a detail whose master is itself a detail) are **not** traversed transitively in v1.
76+
77+
## Phasing
78+
79+
- **P0** — related-record topological synthesis in `@objectstack/verify` (prerequisite for the proof; independently valuable — widens auto-derive coverage).
80+
- **P1** — spec contract (§1) + read derivation (§2) behind the `controlled_by_parent` sharingModel.
81+
- **P2** — write pre-image extension (§3) + the dogfood RLS proof (§4); bind in the liveness ledger; flip the ledger entry to `live` with its `proof`.
82+
83+
## Non-goals
84+
85+
- **Large-scale share-table/join** for huge master sets (v1 uses the id-set; flagged as a future limit).
86+
- **Transitive nested master-detail chains** (v1 is single-level).
87+
- **A permission-model rewrite** — explicitly rejected; this closes one gap on the existing engine.
88+
- **ServiceNow-style scripted/per-row ACL scripts** — over-engineering for an AI-authored platform; the four-form fail-closed compiler is the deliberate ceiling.
89+
- **Client-side enforcement** — authorization is server-enforced; UI affordances are presentation (ADR-0054 §Non-goals).
90+
91+
## Alternatives considered
92+
93+
- **Rewrite the permission model against a mainstream blueprint.** Rejected: the model is already Salesforce-shaped; a rewrite re-opens hard-won invariants (the #1994 by-id-write fix, org-scoping stripping, the fail-closed compiler) for no foundational gain. Gaps are enumerable and closable individually.
94+
- **(a) / (c) access-resolution mechanisms** — see §2 trade-off table.

examples/app-showcase/src/objects/invoice.object.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ export const InvoiceLine = ObjectSchema.create({
103103
icon: 'list',
104104
description: 'A single billable line on an invoice.',
105105

106+
// ADR-0055: a line's access is CONTROLLED BY ITS PARENT invoice — a user sees
107+
// and edits a line only if they can see/edit its `invoice` master. No RLS is
108+
// authored here; the security layer derives it from the required master_detail
109+
// relationship (`invoice`). This is the canonical master-detail use of
110+
// controlled_by_parent (a line is meaningless apart from its invoice).
111+
sharingModel: 'controlled_by_parent',
112+
106113
fields: {
107114
invoice: Field.masterDetail('showcase_invoice', {
108115
label: 'Invoice',

examples/app-showcase/src/pages/task-triage.page.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ export const TaskTriagePage: Page = {
3333
element: 'tabs',
3434
showAllRecords: true,
3535
tabs: [
36-
{ name: 'in_progress', label: 'In Progress', filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }] },
37-
{ name: 'urgent', label: 'Urgent', icon: 'flame', filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }] },
38-
{ name: 'in_review', label: 'In Review', filter: [{ field: 'status', operator: 'equals', value: 'in_review' }] },
39-
{ name: 'done', label: 'Done', filter: [{ field: 'status', operator: 'equals', value: 'done' }] },
36+
{ name: 'in_progress', label: 'In Progress', pinned: false, isDefault: false, visible: true, filter: [{ field: 'status', operator: 'equals', value: 'in_progress' }] },
37+
{ name: 'urgent', label: 'Urgent', icon: 'flame', pinned: false, isDefault: false, visible: true, filter: [{ field: 'priority', operator: 'equals', value: 'urgent' }] },
38+
{ name: 'in_review', label: 'In Review', pinned: false, isDefault: false, visible: true, filter: [{ field: 'status', operator: 'equals', value: 'in_review' }] },
39+
{ name: 'done', label: 'Done', pinned: false, isDefault: false, visible: true, filter: [{ field: 'status', operator: 'equals', value: 'done' }] },
4040
],
4141
},
4242

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Master-detail "controlled by parent" RLS proof (ADR-0055 P2), end-to-end
4+
// through the real HTTP + security stack.
5+
//
6+
// @proof: cbp-controlled-by-parent
7+
// ADR-0055 runtime proof for derived master-detail access. Referenced by the
8+
// liveness ledger entry `object.sharingModel` (packages/spec/liveness/object.json);
9+
// the spec liveness gate fails if this tag is removed. See proof-registry.mts.
10+
//
11+
// The detail (`cbp_note`) carries NO authored RLS — access is DERIVED from the
12+
// master (`cbp_account`, owner-scoped). The proof asserts both directions:
13+
// • a member who cannot read the admin's account can neither READ nor by-id
14+
// WRITE notes under it (the derived guard), and
15+
// • the member CAN read/write notes under an account they own (not over-blocked).
16+
17+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
18+
import { bootStack, type VerifyStack } from '@objectstack/verify';
19+
import { cbpStack, cbpSecurity } from './fixtures/cbp-fixture.js';
20+
21+
describe('objectstack verify: master-detail controlled-by-parent (#cbp)', () => {
22+
let stack: VerifyStack;
23+
let adminToken: string;
24+
let memberToken: string;
25+
26+
// Admin-owned graph (member must NOT reach it).
27+
let adminAccountId: string;
28+
let adminNoteId: string;
29+
30+
beforeAll(async () => {
31+
stack = await bootStack(cbpStack, { security: cbpSecurity() });
32+
adminToken = await stack.signIn();
33+
memberToken = await stack.signUp('cbp-member@verify.test');
34+
35+
const acc = await stack.apiAs(adminToken, 'POST', '/data/cbp_account', { name: 'admin account' });
36+
expect(acc.status, `admin account create: ${acc.status} ${await acc.clone().text()}`).toBeLessThan(300);
37+
adminAccountId = idOf(await acc.json());
38+
39+
const note = await stack.apiAs(adminToken, 'POST', '/data/cbp_note', {
40+
name: 'admin note',
41+
body: 'admin-only',
42+
account: adminAccountId,
43+
});
44+
expect(note.status, `admin note create: ${note.status} ${await note.clone().text()}`).toBeLessThan(300);
45+
adminNoteId = idOf(await note.json());
46+
}, 60_000);
47+
48+
afterAll(async () => {
49+
await stack?.stop();
50+
});
51+
52+
it('precondition: the member cannot read the admin master account (owner RLS)', async () => {
53+
const r = await stack.apiAs(memberToken, 'GET', `/data/cbp_account/${adminAccountId}`);
54+
expect(r.status, 'member must not read the admin account').not.toBe(200);
55+
});
56+
57+
it('DERIVED READ: member cannot read a note under an account they cannot read', async () => {
58+
const r = await stack.apiAs(memberToken, 'GET', `/data/cbp_note/${adminNoteId}`);
59+
expect(r.status, 'member must not read the controlled-by-parent note').not.toBe(200);
60+
});
61+
62+
it('DERIVED WRITE: member cannot by-id mutate a note whose master they cannot edit', async () => {
63+
const w = await stack.apiAs(memberToken, 'PATCH', `/data/cbp_note/${adminNoteId}`, { body: 'hacked' });
64+
expect(w.status, 'member by-id write should be denied').not.toBeLessThan(300);
65+
// Ground truth: admin re-reads — the row must be unchanged.
66+
const after = await stack.apiAs(adminToken, 'GET', `/data/cbp_note/${adminNoteId}`);
67+
expect(after.status).toBe(200);
68+
expect(((await after.json()) as any).record?.body).toBe('admin-only');
69+
});
70+
71+
it('NOT over-blocked: member CAN read + write a note under an account they own', async () => {
72+
// Member owns this account → derived access grants the note under it.
73+
const acc = await stack.apiAs(memberToken, 'POST', '/data/cbp_account', { name: 'member account' });
74+
expect(acc.status).toBeLessThan(300);
75+
const memberAccountId = idOf(await acc.json());
76+
77+
const note = await stack.apiAs(memberToken, 'POST', '/data/cbp_note', {
78+
name: 'member note',
79+
body: 'mine',
80+
account: memberAccountId,
81+
});
82+
expect(note.status, `member note create: ${note.status} ${await note.clone().text()}`).toBeLessThan(300);
83+
const memberNoteId = idOf(await note.json());
84+
85+
// Read it back (derived: account IN [memberAccountId]).
86+
const read = await stack.apiAs(memberToken, 'GET', `/data/cbp_note/${memberNoteId}`);
87+
expect(read.status, 'member should read their own note').toBe(200);
88+
89+
// Edit it (master is member-owned → editable).
90+
const edit = await stack.apiAs(memberToken, 'PATCH', `/data/cbp_note/${memberNoteId}`, { body: 'updated' });
91+
expect(edit.status, 'member should edit their own note').toBeLessThan(300);
92+
const after = await stack.apiAs(memberToken, 'GET', `/data/cbp_note/${memberNoteId}`);
93+
expect(((await after.json()) as any).record?.body).toBe('updated');
94+
});
95+
});
96+
97+
function idOf(j: any): string {
98+
const id = j?.id ?? j?.record?.id;
99+
expect(id, 'expected an id from create').toBeTruthy();
100+
return id as string;
101+
}

0 commit comments

Comments
 (0)