Skip to content

Commit b60577e

Browse files
committed
feat(app-showcase): declarative private-OWD scenario + dogfood proof (ADR-0056)
First execution increment of ADR-0056: demonstrate the declarative owner-private capability on the real showcase. `showcase_private_note` declares `sharingModel: 'private'` + an `owner_id` field and NOTHING else — no RLS, no predicate, no permission-set rule. The engine derives owner scoping from the OWD baseline + auto-stamped owner_id. Proof (`showcase-private-owd.dogfood.test.ts`, 5/5, real HTTP): two plain sign-ups each create notes; each lists only their own, cannot read/write another owner's by id, can write their own — owner_id auto-stamped, zero authored RLS. Full dogfood 11 files / 86 tests; showcase units 20. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent a3514bb commit b60577e

4 files changed

Lines changed: 138 additions & 0 deletions

File tree

.changeset/showcase-private-owd.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/example-showcase": minor
3+
---
4+
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.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export { Team, ProjectMembership } from './team.object.js';
88
export { Product, Invoice, InvoiceLine } from './invoice.object.js';
99
export { FieldZoo } from './field-zoo.object.js';
1010
export { Preference } from './preference.object.js';
11+
export { PrivateNote } from './private-note.object.js';
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* Personal Note — the canonical OWNER-PRIVATE object (ADR-0056, declarative OWD).
7+
*
8+
* It declares `sharingModel: 'private'` and nothing else: no hand-written RLS
9+
* policy, no owner predicate, no permission-set rule. The engine derives owner
10+
* scoping from the Org-Wide-Default baseline + the auto-stamped `owner_id` — a
11+
* user sees and edits only the notes they own.
12+
*
13+
* This is the declarative counterpart to the invoice's hand-written
14+
* `owner = current_user.email` escape-hatch (PR #2054): for plain "my records
15+
* are mine" ownership, an object declares ONE WORD and the platform enforces it.
16+
* The corresponding dogfood proof (`showcase-private-owd.dogfood.test.ts`) shows
17+
* two users each seeing only their own notes through the real HTTP stack.
18+
*/
19+
export const PrivateNote = ObjectSchema.create({
20+
name: 'showcase_private_note',
21+
label: 'Personal Note',
22+
pluralLabel: 'Personal Notes',
23+
icon: 'lock',
24+
description: 'A private journal entry visible only to its owner — declarative `private` OWD (ADR-0056).',
25+
26+
// The entire access policy: owner-private. No RLS authored anywhere.
27+
sharingModel: 'private',
28+
29+
fields: {
30+
title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 160 }),
31+
body: Field.text({ label: 'Body', maxLength: 2000 }),
32+
pinned: Field.boolean({ label: 'Pinned', defaultValue: false }),
33+
// Owner anchor — auto-stamped to the creating user on insert; the `private`
34+
// OWD reads it to scope visibility. Authors declare the field; the engine
35+
// fills it (no manual assignment, no predicate).
36+
owner_id: Field.lookup('sys_user', { label: 'Owner' }),
37+
},
38+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0056 — declarative `private` OWD proof on the REAL showcase app.
4+
// `showcase_private_note` declares `sharingModel: 'private'` and NOTHING else:
5+
// no RLS policy, no owner predicate, no permission-set rule. Two plain sign-ups
6+
// (governed only by the default member set) each create notes; the engine scopes
7+
// every read/write to the owner purely from the OWD baseline + the auto-stamped
8+
// `owner_id`. This is the canonical "declare one word, get owner isolation"
9+
// capability — proven end-to-end through the real HTTP stack.
10+
11+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
12+
import showcaseStack from '@objectstack/example-showcase';
13+
import { bootStack, type VerifyStack } from '@objectstack/verify';
14+
15+
const OBJ = '/data/showcase_private_note';
16+
const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId;
17+
18+
describe('showcase: declarative private OWD (ADR-0056)', () => {
19+
let stack: VerifyStack;
20+
let aToken: string;
21+
let bToken: string;
22+
let aNoteId: string;
23+
let bNoteId: string;
24+
25+
beforeAll(async () => {
26+
stack = await bootStack(showcaseStack);
27+
await stack.signIn(); // seed dev admin (first user)
28+
aToken = await stack.signUp('owd-alice@verify.test');
29+
bToken = await stack.signUp('owd-bob@verify.test');
30+
31+
const a1 = await stack.apiAs(aToken, 'POST', OBJ, { title: 'Alice private 1', body: 'a-body' });
32+
expect(a1.status, 'alice creates note').toBeLessThan(300);
33+
aNoteId = idOf(await a1.json());
34+
await stack.apiAs(aToken, 'POST', OBJ, { title: 'Alice private 2' });
35+
36+
const b1 = await stack.apiAs(bToken, 'POST', OBJ, { title: 'Bob private 1' });
37+
expect(b1.status, 'bob creates note').toBeLessThan(300);
38+
bNoteId = idOf(await b1.json());
39+
40+
expect(aNoteId, 'alice note id').toBeTruthy();
41+
expect(bNoteId, 'bob note id').toBeTruthy();
42+
}, 60_000);
43+
44+
afterAll(async () => { await stack?.stop(); });
45+
46+
it('owner_id is auto-stamped (no manual assignment, no predicate)', async () => {
47+
const ql: any = await stack.kernel.getServiceAsync('objectql');
48+
const row = await ql.findOne('showcase_private_note', { where: { id: aNoteId }, context: { isSystem: true } });
49+
expect(row?.owner_id, 'owner_id stamped to a real user id').toBeTruthy();
50+
});
51+
52+
it('a member LISTS only the notes they own', async () => {
53+
const r = await stack.apiAs(aToken, 'GET', OBJ);
54+
expect(r.status).toBe(200);
55+
const body: any = await r.json();
56+
const titles: string[] = (body.records ?? body.data ?? body ?? []).map((x: any) => x.title);
57+
expect(titles).toContain('Alice private 1');
58+
expect(titles).toContain('Alice private 2');
59+
expect(titles).not.toContain('Bob private 1'); // owner isolation, no RLS authored
60+
});
61+
62+
it('a member cannot READ another owner’s note by id', async () => {
63+
const r = await stack.apiAs(aToken, 'GET', `${OBJ}/${bNoteId}`);
64+
expect(r.status, 'alice must not read bob note').not.toBe(200);
65+
});
66+
67+
it('a member cannot WRITE another owner’s note, but can write their own', async () => {
68+
const foreign = await stack.apiAs(aToken, 'PATCH', `${OBJ}/${bNoteId}`, { body: 'hacked' });
69+
expect(foreign.status, 'alice must not edit bob note').not.toBeLessThan(300);
70+
71+
const own = await stack.apiAs(aToken, 'PATCH', `${OBJ}/${aNoteId}`, { body: 'updated' });
72+
expect(own.status, 'alice edits her own note').toBeLessThan(300);
73+
});
74+
75+
it('the OTHER member is symmetric (bob sees only his own)', async () => {
76+
const r = await stack.apiAs(bToken, 'GET', OBJ);
77+
const body: any = await r.json();
78+
const titles: string[] = (body.records ?? body.data ?? body ?? []).map((x: any) => x.title);
79+
expect(titles).toContain('Bob private 1');
80+
expect(titles).not.toContain('Alice private 1');
81+
const foreign = await stack.apiAs(bToken, 'GET', `${OBJ}/${aNoteId}`);
82+
expect(foreign.status).not.toBe(200);
83+
});
84+
});

0 commit comments

Comments
 (0)