Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/showcase-private-owd.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@objectstack/example-showcase": minor
---

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).
31 changes: 31 additions & 0 deletions examples/app-showcase/src/objects/announcement.object.ts
Original file line number Diff line number Diff line change
@@ -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' }),
},
});
2 changes: 2 additions & 0 deletions examples/app-showcase/src/objects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ 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';
export { Announcement } from './announcement.object.js';
38 changes: 38 additions & 0 deletions examples/app-showcase/src/objects/private-note.object.ts
Original file line number Diff line number Diff line change
@@ -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' }),
},
});
84 changes: 84 additions & 0 deletions packages/dogfood/test/showcase-private-owd.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
60 changes: 60 additions & 0 deletions packages/dogfood/test/showcase-public-read-owd.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading