From b60577e5afb157d477fb9e06add78109461910b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 07:01:10 +0000 Subject: [PATCH 1/2] feat(app-showcase): declarative private-OWD scenario + dogfood proof (ADR-0056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- .changeset/showcase-private-owd.md | 15 ++++ examples/app-showcase/src/objects/index.ts | 1 + .../src/objects/private-note.object.ts | 38 +++++++++ .../test/showcase-private-owd.dogfood.test.ts | 84 +++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 .changeset/showcase-private-owd.md create mode 100644 examples/app-showcase/src/objects/private-note.object.ts create mode 100644 packages/dogfood/test/showcase-private-owd.dogfood.test.ts diff --git a/.changeset/showcase-private-owd.md b/.changeset/showcase-private-owd.md new file mode 100644 index 0000000000..013de26091 --- /dev/null +++ b/.changeset/showcase-private-owd.md @@ -0,0 +1,15 @@ +--- +"@objectstack/example-showcase": minor +--- + +feat(app-showcase): declarative owner-private object (`showcase_private_note`, ADR-0056) + +Adds the canonical **declarative `private` OWD** scenario: `showcase_private_note` +declares `sharingModel: 'private'` and an `owner_id` field — and nothing else (no +RLS policy, no owner predicate, no permission-set rule). The engine derives owner +scoping from the OWD baseline + the auto-stamped `owner_id`, so a user sees and +edits only the notes they own. This is the declarative counterpart to the invoice's +hand-written `owner = current_user.email` escape-hatch — for plain "my records are +mine" ownership, an object declares one word and the platform enforces it. Proven +end-to-end (two users, owner isolation on read + by-id read/write) by the new +`showcase-private-owd` dogfood test. diff --git a/examples/app-showcase/src/objects/index.ts b/examples/app-showcase/src/objects/index.ts index f2505be065..24bec32420 100644 --- a/examples/app-showcase/src/objects/index.ts +++ b/examples/app-showcase/src/objects/index.ts @@ -8,3 +8,4 @@ export { Team, ProjectMembership } from './team.object.js'; export { Product, Invoice, InvoiceLine } from './invoice.object.js'; export { FieldZoo } from './field-zoo.object.js'; export { Preference } from './preference.object.js'; +export { PrivateNote } from './private-note.object.js'; diff --git a/examples/app-showcase/src/objects/private-note.object.ts b/examples/app-showcase/src/objects/private-note.object.ts new file mode 100644 index 0000000000..a214a65226 --- /dev/null +++ b/examples/app-showcase/src/objects/private-note.object.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * Personal Note — the canonical OWNER-PRIVATE object (ADR-0056, declarative OWD). + * + * It declares `sharingModel: 'private'` and nothing else: no hand-written RLS + * policy, no owner predicate, no permission-set rule. The engine derives owner + * scoping from the Org-Wide-Default baseline + the auto-stamped `owner_id` — a + * user sees and edits only the notes they own. + * + * This is the declarative counterpart to the invoice's hand-written + * `owner = current_user.email` escape-hatch (PR #2054): for plain "my records + * are mine" ownership, an object declares ONE WORD and the platform enforces it. + * The corresponding dogfood proof (`showcase-private-owd.dogfood.test.ts`) shows + * two users each seeing only their own notes through the real HTTP stack. + */ +export const PrivateNote = ObjectSchema.create({ + name: 'showcase_private_note', + label: 'Personal Note', + pluralLabel: 'Personal Notes', + icon: 'lock', + description: 'A private journal entry visible only to its owner — declarative `private` OWD (ADR-0056).', + + // The entire access policy: owner-private. No RLS authored anywhere. + sharingModel: 'private', + + fields: { + title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 160 }), + body: Field.text({ label: 'Body', maxLength: 2000 }), + pinned: Field.boolean({ label: 'Pinned', defaultValue: false }), + // Owner anchor — auto-stamped to the creating user on insert; the `private` + // OWD reads it to scope visibility. Authors declare the field; the engine + // fills it (no manual assignment, no predicate). + owner_id: Field.lookup('sys_user', { label: 'Owner' }), + }, +}); diff --git a/packages/dogfood/test/showcase-private-owd.dogfood.test.ts b/packages/dogfood/test/showcase-private-owd.dogfood.test.ts new file mode 100644 index 0000000000..581bfe37f2 --- /dev/null +++ b/packages/dogfood/test/showcase-private-owd.dogfood.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0056 — declarative `private` OWD proof on the REAL showcase app. +// `showcase_private_note` declares `sharingModel: 'private'` and NOTHING else: +// no RLS policy, no owner predicate, no permission-set rule. Two plain sign-ups +// (governed only by the default member set) each create notes; the engine scopes +// every read/write to the owner purely from the OWD baseline + the auto-stamped +// `owner_id`. This is the canonical "declare one word, get owner isolation" +// capability — proven end-to-end through the real HTTP stack. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const OBJ = '/data/showcase_private_note'; +const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId; + +describe('showcase: declarative private OWD (ADR-0056)', () => { + let stack: VerifyStack; + let aToken: string; + let bToken: string; + let aNoteId: string; + let bNoteId: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + await stack.signIn(); // seed dev admin (first user) + aToken = await stack.signUp('owd-alice@verify.test'); + bToken = await stack.signUp('owd-bob@verify.test'); + + const a1 = await stack.apiAs(aToken, 'POST', OBJ, { title: 'Alice private 1', body: 'a-body' }); + expect(a1.status, 'alice creates note').toBeLessThan(300); + aNoteId = idOf(await a1.json()); + await stack.apiAs(aToken, 'POST', OBJ, { title: 'Alice private 2' }); + + const b1 = await stack.apiAs(bToken, 'POST', OBJ, { title: 'Bob private 1' }); + expect(b1.status, 'bob creates note').toBeLessThan(300); + bNoteId = idOf(await b1.json()); + + expect(aNoteId, 'alice note id').toBeTruthy(); + expect(bNoteId, 'bob note id').toBeTruthy(); + }, 60_000); + + afterAll(async () => { await stack?.stop(); }); + + it('owner_id is auto-stamped (no manual assignment, no predicate)', async () => { + const ql: any = await stack.kernel.getServiceAsync('objectql'); + const row = await ql.findOne('showcase_private_note', { where: { id: aNoteId }, context: { isSystem: true } }); + expect(row?.owner_id, 'owner_id stamped to a real user id').toBeTruthy(); + }); + + it('a member LISTS only the notes they own', async () => { + const r = await stack.apiAs(aToken, 'GET', OBJ); + expect(r.status).toBe(200); + const body: any = await r.json(); + const titles: string[] = (body.records ?? body.data ?? body ?? []).map((x: any) => x.title); + expect(titles).toContain('Alice private 1'); + expect(titles).toContain('Alice private 2'); + expect(titles).not.toContain('Bob private 1'); // owner isolation, no RLS authored + }); + + it('a member cannot READ another owner’s note by id', async () => { + const r = await stack.apiAs(aToken, 'GET', `${OBJ}/${bNoteId}`); + expect(r.status, 'alice must not read bob note').not.toBe(200); + }); + + it('a member cannot WRITE another owner’s note, but can write their own', async () => { + const foreign = await stack.apiAs(aToken, 'PATCH', `${OBJ}/${bNoteId}`, { body: 'hacked' }); + expect(foreign.status, 'alice must not edit bob note').not.toBeLessThan(300); + + const own = await stack.apiAs(aToken, 'PATCH', `${OBJ}/${aNoteId}`, { body: 'updated' }); + expect(own.status, 'alice edits her own note').toBeLessThan(300); + }); + + it('the OTHER member is symmetric (bob sees only his own)', async () => { + const r = await stack.apiAs(bToken, 'GET', OBJ); + const body: any = await r.json(); + const titles: string[] = (body.records ?? body.data ?? body ?? []).map((x: any) => x.title); + expect(titles).toContain('Bob private 1'); + expect(titles).not.toContain('Alice private 1'); + const foreign = await stack.apiAs(bToken, 'GET', `${OBJ}/${aNoteId}`); + expect(foreign.status).not.toBe(200); + }); +}); From 6c55804630b7e2eaa601bd990d9d203da83b745f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 07:04:31 +0000 Subject: [PATCH 2/2] feat(app-showcase): public-read OWD scenario + proof (ADR-0056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- .changeset/showcase-private-owd.md | 27 +++++---- .../src/objects/announcement.object.ts | 31 ++++++++++ examples/app-showcase/src/objects/index.ts | 1 + .../showcase-public-read-owd.dogfood.test.ts | 60 +++++++++++++++++++ 4 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 examples/app-showcase/src/objects/announcement.object.ts create mode 100644 packages/dogfood/test/showcase-public-read-owd.dogfood.test.ts diff --git a/.changeset/showcase-private-owd.md b/.changeset/showcase-private-owd.md index 013de26091..fb8e2899b8 100644 --- a/.changeset/showcase-private-owd.md +++ b/.changeset/showcase-private-owd.md @@ -2,14 +2,19 @@ "@objectstack/example-showcase": minor --- -feat(app-showcase): declarative owner-private object (`showcase_private_note`, ADR-0056) - -Adds the canonical **declarative `private` OWD** scenario: `showcase_private_note` -declares `sharingModel: 'private'` and an `owner_id` field — and nothing else (no -RLS policy, no owner predicate, no permission-set rule). The engine derives owner -scoping from the OWD baseline + the auto-stamped `owner_id`, so a user sees and -edits only the notes they own. This is the declarative counterpart to the invoice's -hand-written `owner = current_user.email` escape-hatch — for plain "my records are -mine" ownership, an object declares one word and the platform enforces it. Proven -end-to-end (two users, owner isolation on read + by-id read/write) by the new -`showcase-private-owd` dogfood test. +feat(app-showcase): declarative OWD scenarios — owner-private + public-read (ADR-0056) + +Adds the two canonical Org-Wide-Default scenarios, each declaring its access policy +in ONE word with no authored RLS: + +- `showcase_private_note` — `sharingModel: 'private'`: a user sees and edits only + the notes they own (owner-only read + write). +- `showcase_announcement` — `sharingModel: 'read'`: every member reads every + announcement, but only the owner may edit/delete it (public-read). + +Both derive scoping from the OWD baseline + the auto-stamped `owner_id` — the +declarative counterpart to the invoice's hand-written `owner = current_user.email` +escape-hatch. Proven end-to-end (two users, real HTTP) by the new +`showcase-private-owd` and `showcase-public-read-owd` dogfood tests, which together +demonstrate the OWD read-visibility axis (`private` hides others' rows; `read` +shows them but still protects writes). diff --git a/examples/app-showcase/src/objects/announcement.object.ts b/examples/app-showcase/src/objects/announcement.object.ts new file mode 100644 index 0000000000..071d414e8e --- /dev/null +++ b/examples/app-showcase/src/objects/announcement.object.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * Team Announcement — the canonical PUBLIC-READ object (ADR-0056 OWD). + * + * Declares `sharingModel: 'read'`: every member can READ every announcement, + * but only the OWNER may edit or delete it (the engine derives "everyone reads, + * owner writes" from the OWD baseline + the auto-stamped `owner_id`). No RLS + * policy is authored. This is the sibling of `showcase_private_note` + * (`sharingModel: 'private'`, owner-only read): together the two objects + * demonstrate the OWD read-visibility axis — `private` hides others' rows, + * `read` shows them but still protects writes. + */ +export const Announcement = ObjectSchema.create({ + name: 'showcase_announcement', + label: 'Announcement', + pluralLabel: 'Announcements', + icon: 'megaphone', + description: 'A team announcement everyone can read but only its owner can edit — `read` OWD (ADR-0056).', + + // Everyone reads; owner writes. No RLS authored. + sharingModel: 'read', + + fields: { + title: Field.text({ label: 'Title', required: true, searchable: true, maxLength: 160 }), + body: Field.text({ label: 'Body', maxLength: 2000 }), + owner_id: Field.lookup('sys_user', { label: 'Owner' }), + }, +}); diff --git a/examples/app-showcase/src/objects/index.ts b/examples/app-showcase/src/objects/index.ts index 24bec32420..954354c956 100644 --- a/examples/app-showcase/src/objects/index.ts +++ b/examples/app-showcase/src/objects/index.ts @@ -9,3 +9,4 @@ export { Product, Invoice, InvoiceLine } from './invoice.object.js'; export { FieldZoo } from './field-zoo.object.js'; export { Preference } from './preference.object.js'; export { PrivateNote } from './private-note.object.js'; +export { Announcement } from './announcement.object.js'; diff --git a/packages/dogfood/test/showcase-public-read-owd.dogfood.test.ts b/packages/dogfood/test/showcase-public-read-owd.dogfood.test.ts new file mode 100644 index 0000000000..a0f262e31c --- /dev/null +++ b/packages/dogfood/test/showcase-public-read-owd.dogfood.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0056 — `read` (public-read) OWD proof on the REAL showcase app. +// `showcase_announcement` declares `sharingModel: 'read'` and nothing else: every +// member READS every announcement, but only the OWNER may edit/delete it — derived +// from the OWD baseline + auto-stamped `owner_id`, no RLS authored. This is the +// sibling of the `private` proof: same owner-write protection, but rows are +// VISIBLE across owners (the read-visibility axis of OWD). + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const OBJ = '/data/showcase_announcement'; +const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId; + +describe('showcase: public-read OWD (ADR-0056)', () => { + let stack: VerifyStack; + let aToken: string; + let bToken: string; + let aAnnId: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack); + await stack.signIn(); + aToken = await stack.signUp('pr-alice@verify.test'); + bToken = await stack.signUp('pr-bob@verify.test'); + + const a = await stack.apiAs(aToken, 'POST', OBJ, { title: 'Alice announces', body: 'hello team' }); + expect(a.status).toBeLessThan(300); + aAnnId = idOf(await a.json()); + expect(aAnnId).toBeTruthy(); + }, 60_000); + + afterAll(async () => { await stack?.stop(); }); + + it('every member READS another owner’s announcement (public-read)', async () => { + const byId = await stack.apiAs(bToken, 'GET', `${OBJ}/${aAnnId}`); + expect(byId.status, 'bob reads alice announcement by id').toBe(200); + + const list = await stack.apiAs(bToken, 'GET', OBJ); + const titles: string[] = ((await list.json()).records ?? []).map((x: any) => x.title); + expect(titles, 'bob sees alice announcement in the list').toContain('Alice announces'); + }); + + it('but only the OWNER may edit it', async () => { + const foreign = await stack.apiAs(bToken, 'PATCH', `${OBJ}/${aAnnId}`, { body: 'tampered' }); + expect(foreign.status, 'bob must not edit alice announcement').not.toBeLessThan(300); + + const own = await stack.apiAs(aToken, 'PATCH', `${OBJ}/${aAnnId}`, { body: 'edited by owner' }); + expect(own.status, 'alice edits her own announcement').toBeLessThan(300); + }); + + it('contrast with private: read-visibility is the distinguishing axis', async () => { + // (sanity) the announcement is readable cross-owner — the very thing the + // private note forbids — confirming `read` vs `private` are distinct OWDs. + const r = await stack.apiAs(bToken, 'GET', `${OBJ}/${aAnnId}`); + expect(r.status).toBe(200); + }); +});