Skip to content

Commit 6c55804

Browse files
committed
feat(app-showcase): public-read OWD scenario + proof (ADR-0056)
Sibling of the private-note scenario: showcase_announcement declares sharingModel: 'read' (everyone reads, only owner edits) — no RLS authored. Proof showcase-public-read-owd (3 tests) shows cross-owner READ allowed but cross-owner WRITE denied, contrasting the private note's hidden rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent b60577e commit 6c55804

4 files changed

Lines changed: 108 additions & 11 deletions

File tree

.changeset/showcase-private-owd.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@
22
"@objectstack/example-showcase": minor
33
---
44

5-
feat(app-showcase): declarative owner-private object (`showcase_private_note`, ADR-0056)
6-
7-
Adds the canonical **declarative `private` OWD** scenario: `showcase_private_note`
8-
declares `sharingModel: 'private'` and an `owner_id` field — and nothing else (no
9-
RLS policy, no owner predicate, no permission-set rule). The engine derives owner
10-
scoping from the OWD baseline + the auto-stamped `owner_id`, so a user sees and
11-
edits only the notes they own. This is the declarative counterpart to the invoice's
12-
hand-written `owner = current_user.email` escape-hatch — for plain "my records are
13-
mine" ownership, an object declares one word and the platform enforces it. Proven
14-
end-to-end (two users, owner isolation on read + by-id read/write) by the new
15-
`showcase-private-owd` dogfood test.
5+
feat(app-showcase): declarative OWD scenarios — owner-private + public-read (ADR-0056)
6+
7+
Adds the two canonical Org-Wide-Default scenarios, each declaring its access policy
8+
in ONE word with no authored RLS:
9+
10+
- `showcase_private_note``sharingModel: 'private'`: a user sees and edits only
11+
the notes they own (owner-only read + write).
12+
- `showcase_announcement``sharingModel: 'read'`: every member reads every
13+
announcement, but only the owner may edit/delete it (public-read).
14+
15+
Both derive scoping from the OWD baseline + the auto-stamped `owner_id` — the
16+
declarative counterpart to the invoice's hand-written `owner = current_user.email`
17+
escape-hatch. Proven end-to-end (two users, real HTTP) by the new
18+
`showcase-private-owd` and `showcase-public-read-owd` dogfood tests, which together
19+
demonstrate the OWD read-visibility axis (`private` hides others' rows; `read`
20+
shows them but still protects writes).
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* Team Announcement — the canonical PUBLIC-READ object (ADR-0056 OWD).
7+
*
8+
* Declares `sharingModel: 'read'`: every member can READ every announcement,
9+
* but only the OWNER may edit or delete it (the engine derives "everyone reads,
10+
* owner writes" from the OWD baseline + the auto-stamped `owner_id`). No RLS
11+
* policy is authored. This is the sibling of `showcase_private_note`
12+
* (`sharingModel: 'private'`, owner-only read): together the two objects
13+
* demonstrate the OWD read-visibility axis — `private` hides others' rows,
14+
* `read` shows them but still protects writes.
15+
*/
16+
export const Announcement = ObjectSchema.create({
17+
name: 'showcase_announcement',
18+
label: 'Announcement',
19+
pluralLabel: 'Announcements',
20+
icon: 'megaphone',
21+
description: 'A team announcement everyone can read but only its owner can edit — `read` OWD (ADR-0056).',
22+
23+
// Everyone reads; owner writes. No RLS authored.
24+
sharingModel: 'read',
25+
26+
fields: {
27+
title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 160 }),
28+
body: Field.text({ label: 'Body', maxLength: 2000 }),
29+
owner_id: Field.lookup('sys_user', { label: 'Owner' }),
30+
},
31+
});

examples/app-showcase/src/objects/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export { Product, Invoice, InvoiceLine } from './invoice.object.js';
99
export { FieldZoo } from './field-zoo.object.js';
1010
export { Preference } from './preference.object.js';
1111
export { PrivateNote } from './private-note.object.js';
12+
export { Announcement } from './announcement.object.js';
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0056 — `read` (public-read) OWD proof on the REAL showcase app.
4+
// `showcase_announcement` declares `sharingModel: 'read'` and nothing else: every
5+
// member READS every announcement, but only the OWNER may edit/delete it — derived
6+
// from the OWD baseline + auto-stamped `owner_id`, no RLS authored. This is the
7+
// sibling of the `private` proof: same owner-write protection, but rows are
8+
// VISIBLE across owners (the read-visibility axis of OWD).
9+
10+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
11+
import showcaseStack from '@objectstack/example-showcase';
12+
import { bootStack, type VerifyStack } from '@objectstack/verify';
13+
14+
const OBJ = '/data/showcase_announcement';
15+
const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId;
16+
17+
describe('showcase: public-read OWD (ADR-0056)', () => {
18+
let stack: VerifyStack;
19+
let aToken: string;
20+
let bToken: string;
21+
let aAnnId: string;
22+
23+
beforeAll(async () => {
24+
stack = await bootStack(showcaseStack);
25+
await stack.signIn();
26+
aToken = await stack.signUp('pr-alice@verify.test');
27+
bToken = await stack.signUp('pr-bob@verify.test');
28+
29+
const a = await stack.apiAs(aToken, 'POST', OBJ, { title: 'Alice announces', body: 'hello team' });
30+
expect(a.status).toBeLessThan(300);
31+
aAnnId = idOf(await a.json());
32+
expect(aAnnId).toBeTruthy();
33+
}, 60_000);
34+
35+
afterAll(async () => { await stack?.stop(); });
36+
37+
it('every member READS another owner’s announcement (public-read)', async () => {
38+
const byId = await stack.apiAs(bToken, 'GET', `${OBJ}/${aAnnId}`);
39+
expect(byId.status, 'bob reads alice announcement by id').toBe(200);
40+
41+
const list = await stack.apiAs(bToken, 'GET', OBJ);
42+
const titles: string[] = ((await list.json()).records ?? []).map((x: any) => x.title);
43+
expect(titles, 'bob sees alice announcement in the list').toContain('Alice announces');
44+
});
45+
46+
it('but only the OWNER may edit it', async () => {
47+
const foreign = await stack.apiAs(bToken, 'PATCH', `${OBJ}/${aAnnId}`, { body: 'tampered' });
48+
expect(foreign.status, 'bob must not edit alice announcement').not.toBeLessThan(300);
49+
50+
const own = await stack.apiAs(aToken, 'PATCH', `${OBJ}/${aAnnId}`, { body: 'edited by owner' });
51+
expect(own.status, 'alice edits her own announcement').toBeLessThan(300);
52+
});
53+
54+
it('contrast with private: read-visibility is the distinguishing axis', async () => {
55+
// (sanity) the announcement is readable cross-owner — the very thing the
56+
// private note forbids — confirming `read` vs `private` are distinct OWDs.
57+
const r = await stack.apiAs(bToken, 'GET', `${OBJ}/${aAnnId}`);
58+
expect(r.status).toBe(200);
59+
});
60+
});

0 commit comments

Comments
 (0)