From 034b867148f81d17a727f249953058aba78b58fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 11:17:09 +0000 Subject: [PATCH 1/5] feat(app-showcase): web-to-lead public form demonstrating ADR-0056 Option A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `showcase_inquiry` object + a public FormView (`allowAnonymous` + `publicLink: /forms/contact-us`) so `pnpm dev` ships a working anonymous web-to-lead form. The submit route authorizes via the declaration-derived `publicFormGrant` (create + read-back on `showcase_inquiry` only) — no `guest_portal` profile, even under secure-by-default auth. A beforeInsert hook stamps server-controlled defaults (status=new, source=web). Dogfood proof (`showcase-public-form.dogfood.test.ts`) drives the real HTTP routes end-to-end under `requireAuth: true`: GET resolves the form + whitelisted schema, POST creates the inquiry anonymously with stamped defaults, and a general anonymous read is still denied (the grant is create + read-back only). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- examples/app-showcase/objectstack.config.ts | 4 +- examples/app-showcase/src/hooks/index.ts | 24 ++++++ examples/app-showcase/src/objects/index.ts | 1 + .../src/objects/inquiry.object.ts | 49 +++++++++++ examples/app-showcase/src/views/index.ts | 1 + .../app-showcase/src/views/inquiry.view.ts | 84 +++++++++++++++++++ .../test/showcase-public-form.dogfood.test.ts | 69 +++++++++++++++ 7 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 examples/app-showcase/src/objects/inquiry.object.ts create mode 100644 examples/app-showcase/src/views/inquiry.view.ts create mode 100644 packages/dogfood/test/showcase-public-form.dogfood.test.ts diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 013bc8f539..55d84d1a5f 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -12,7 +12,7 @@ import { } from '@objectstack/cloud-connection'; import * as objects from './src/objects/index.js'; -import { TaskViews, ProjectViews } from './src/views/index.js'; +import { TaskViews, ProjectViews, InquiryViews } from './src/views/index.js'; import { ShowcaseApp } from './src/apps/index.js'; import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js'; import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js'; @@ -142,7 +142,7 @@ export default defineStack({ // UI apps: [ShowcaseApp], portals: allPortals, - views: [TaskViews, ProjectViews], + views: [TaskViews, ProjectViews, InquiryViews], pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage], dashboards: [ChartGalleryDashboard, OpsDashboard], books: allBooks, diff --git a/examples/app-showcase/src/hooks/index.ts b/examples/app-showcase/src/hooks/index.ts index 35fbd448d1..c096a5d9df 100644 --- a/examples/app-showcase/src/hooks/index.ts +++ b/examples/app-showcase/src/hooks/index.ts @@ -78,8 +78,32 @@ export const WarnOverBudgetHook = { description: 'Emits a warning when a project’s spend exceeds its budget.', }; +/** + * beforeInsert — stamp server-controlled defaults on a public inquiry. + * + * The web-to-lead public form (ADR-0056 Option A) lets anonymous visitors + * INSERT a `showcase_inquiry`. Its field whitelist already excludes `status` / + * `source`, but this hook is the server-side belt-and-braces: it stamps + * `status = 'new'` and `source = 'web'` so an inquiry can never arrive + * pre-triaged, regardless of how the request was crafted. + */ +export const StampInquiryDefaultsHook = { + name: 'showcase_stamp_inquiry_defaults', + label: 'Stamp Inquiry Defaults', + object: 'showcase_inquiry', + events: ['beforeInsert'] as LifecycleEvent[], + body: { + language: 'js' as const, + source: "if (!ctx.input.status) ctx.input.status = 'new'; ctx.input.source = 'web';", + }, + priority: 50, + onError: 'abort' as const, + description: 'Stamps status=new and source=web on every new inquiry (public web-to-lead defaults).', +}; + export const allHooks = [ NormalizeTaskTitleHook, + StampInquiryDefaultsHook, AuditTaskCompletionHook, WarnOverBudgetHook, ]; diff --git a/examples/app-showcase/src/objects/index.ts b/examples/app-showcase/src/objects/index.ts index 954354c956..fd6c1428e6 100644 --- a/examples/app-showcase/src/objects/index.ts +++ b/examples/app-showcase/src/objects/index.ts @@ -10,3 +10,4 @@ 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'; +export { Inquiry } from './inquiry.object.js'; diff --git a/examples/app-showcase/src/objects/inquiry.object.ts b/examples/app-showcase/src/objects/inquiry.object.ts new file mode 100644 index 0000000000..16e6aee385 --- /dev/null +++ b/examples/app-showcase/src/objects/inquiry.object.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * Inquiry — the inbox behind the PUBLIC FORM (ADR-0056 Option A). + * + * This is the Salesforce *Web-to-Lead* target: a "Contact Us / Request a Demo" + * form is exposed to anonymous visitors (see `views/inquiry.view.ts`, + * `sharing.allowAnonymous: true`). The submit route DERIVES authorization from + * the form's own declaration — a narrow `publicFormGrant: { object: + * 'showcase_inquiry' }` — so an anonymous POST can create exactly one inquiry + * (and read it back) and nothing else, with NO `guest_portal` profile required + * and even under secure-by-default (`requireAuth`). The grant is create + + * read-back only; everything authenticated staff need (triage, status changes) + * comes from a normal permission set. + * + * `sharingModel: 'private'` keeps inquiries owner/staff-scoped once inside — + * the public path only ever inserts, never lists. + */ +export const Inquiry = ObjectSchema.create({ + name: 'showcase_inquiry', + label: 'Inquiry', + pluralLabel: 'Inquiries', + icon: 'mail', + description: 'A public contact-form submission — created anonymously via the web-to-lead public form (ADR-0056 Option A).', + + // Once inside, an inquiry is staff-only. The public form does not read the + // list; it only inserts + reads back the row it just created. + sharingModel: 'private', + + fields: { + name: Field.text({ label: 'Name', required: true, searchable: true, maxLength: 120 }), + email: Field.email({ label: 'Email', required: true, searchable: true }), + company: Field.text({ label: 'Company', maxLength: 120 }), + message: Field.text({ label: 'Message', required: true, maxLength: 2000 }), + // Server-controlled — anonymous submitters can never set these (the form + // whitelist excludes them and the guest-defaults hook stamps/strips them). + status: Field.select({ + label: 'Status', + options: [ + { label: 'New', value: 'new', default: true, color: '#3B82F6' }, + { label: 'Contacted', value: 'contacted', color: '#F59E0B' }, + { label: 'Closed', value: 'closed', color: '#10B981' }, + ], + }), + source: Field.text({ label: 'Source', maxLength: 40 }), + }, +}); diff --git a/examples/app-showcase/src/views/index.ts b/examples/app-showcase/src/views/index.ts index 55ffc1508e..f9e220a906 100644 --- a/examples/app-showcase/src/views/index.ts +++ b/examples/app-showcase/src/views/index.ts @@ -2,3 +2,4 @@ export { TaskViews } from './task.view.js'; export { ProjectViews } from './project.view.js'; +export { InquiryViews } from './inquiry.view.js'; diff --git a/examples/app-showcase/src/views/inquiry.view.ts b/examples/app-showcase/src/views/inquiry.view.ts new file mode 100644 index 0000000000..653c061372 --- /dev/null +++ b/examples/app-showcase/src/views/inquiry.view.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineView } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'showcase_inquiry' }; + +/** + * Inquiry views — including the PUBLIC web-to-lead form (ADR-0056 Option A). + * + * `formViews.contact` declares `sharing.allowAnonymous: true` + a `publicLink` + * slug, which wires the anonymous REST endpoints automatically: + * + * GET /api/v1/forms/contact-us → resolved form + whitelisted schema + * POST /api/v1/forms/contact-us/submit → INSERT a showcase_inquiry + * + * The submit route DERIVES authorization from this declaration — a narrow + * `publicFormGrant: { object: 'showcase_inquiry' }` — so it works even though + * the showcase boots with secure-by-default auth, and WITHOUT any + * `guest_portal` profile. Only the `sections[].fields` below are accepted on + * submit; `status` / `source` are stamped server-side by the guest-defaults + * hook (`hooks/index.ts`). + */ +export const InquiryViews = defineView({ + name: 'showcase_inquiry_views', + object: 'showcase_inquiry', + // Default list shown when the object is opened — carries `data` so the view + // registrar can resolve the target object (without it the whole view, public + // form included, is dropped). + list: { + label: 'Inquiries', + type: 'grid', + data, + columns: [ + { field: 'name' }, + { field: 'email' }, + { field: 'company' }, + { field: 'status' }, + ], + }, + listViews: { + triage: { + type: 'grid', + label: 'Inquiry Triage', + data, + columns: [ + { field: 'name' }, + { field: 'email' }, + { field: 'company' }, + { field: 'status' }, + ], + }, + }, + formViews: { + // PUBLIC — anonymous web-to-lead. The whitelist below is the authoritative + // "what the public may set"; everything else is stripped server-side. + contact: { + type: 'simple', + label: 'Contact Us', + data, + sections: [ + { + label: 'Tell us about yourself', + columns: 1, + fields: [ + { field: 'name', required: true }, + { field: 'email', required: true }, + { field: 'company' }, + { field: 'message', required: true }, + ], + }, + ], + sharing: { + enabled: true, + allowAnonymous: true, + publicLink: '/forms/contact-us', + }, + submitBehavior: { + kind: 'thank-you', + title: 'Thanks!', + message: 'We received your message and a specialist will reach out shortly.', + }, + }, + }, +}); diff --git a/packages/dogfood/test/showcase-public-form.dogfood.test.ts b/packages/dogfood/test/showcase-public-form.dogfood.test.ts new file mode 100644 index 0000000000..a045827cda --- /dev/null +++ b/packages/dogfood/test/showcase-public-form.dogfood.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// SHOWCASE proof for ADR-0056 Option A — the `showcase_inquiry` web-to-lead +// PUBLIC FORM. `views/inquiry.view.ts` declares a FormView with +// `sharing.allowAnonymous: true` + `publicLink: '/forms/contact-us'`, which +// wires the anonymous REST endpoints. This exercises them end-to-end over the +// real HTTP stack — the harness boots with `requireAuth: true`, so a passing +// anonymous submit proves the route works under SECURE-BY-DEFAULT auth, with +// NO `guest_portal` profile, authorized solely by the declaration-derived +// `publicFormGrant` (create + read-back on `showcase_inquiry` ONLY). + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; + +describe('showcase: web-to-lead public form (ADR-0056 Option A)', () => { + let stack: VerifyStack; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets], + }), + }); + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('GET /forms/:slug returns the form + a whitelisted schema (no auth)', async () => { + const r = await stack.api('/forms/contact-us'); + expect(r.status, 'anonymous form resolve must succeed').toBe(200); + const body = (await r.json()) as { object: string; form: unknown; objectSchema: { fields: Record } }; + expect(body.object).toBe('showcase_inquiry'); + // Only the whitelisted form fields are exposed — server-controlled fields are absent. + const fields = Object.keys(body.objectSchema.fields); + expect(fields.sort()).toEqual(['company', 'email', 'message', 'name']); + expect(fields).not.toContain('status'); + expect(fields).not.toContain('source'); + }); + + it('POST /forms/:slug/submit creates an inquiry anonymously under requireAuth (Option A)', async () => { + const r = await stack.api('/forms/contact-us/submit', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name: 'Ada Lovelace', + email: 'ada@example.com', + company: 'Analytical Engines Ltd', + message: 'Please contact me about a pilot.', + status: 'closed', // ← not in whitelist → stripped; hook stamps 'new' + }), + }); + expect(r.status, 'anonymous submit must succeed under requireAuth=true').toBe(201); + const body = (await r.json()) as { object: string; id: string; record: Record }; + expect(body.object).toBe('showcase_inquiry'); + expect(body.record.name).toBe('Ada Lovelace'); + // Server-controlled: whitelist stripped the client `status`, the hook stamped defaults. + expect(body.record.status, 'status is server-stamped, not client-set').toBe('new'); + expect(body.record.source).toBe('web'); + }); + + it('the public grant is create + read-back ONLY — anonymous cannot list inquiries', async () => { + const r = await stack.api('/data/showcase_inquiry'); + expect(r.status, 'general anonymous read must NOT be opened by the form grant').not.toBe(200); + }); +}); From 20022b7ca965b4765f41f17d1d649520a4099f15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 11:33:09 +0000 Subject: [PATCH 2/5] feat(security): wire app-declared default profile through the CLI (ADR-0056 D7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D7 added `isDefault` to PermissionSet and resolves it in the SecurityPlugin constructor — but only over the plugin's built-in `defaultPermissionSets`. The CLI boots `new SecurityPlugin()` with no options, so an `isDefault` profile declared purely in APP METADATA was silently ignored under `pnpm dev`. This closes that gap: - `appDefaultProfileName(permissions)` — exported helper that extracts the first `isProfile && isDefault` profile name from a stack's permissions. - CLI `serve.ts` calls it and passes the name as `fallbackPermissionSet` (undefined → built-in default preserved, so non-declaring apps are unaffected). At request time the name resolves through the metadata service / `sys_permission_set` like any user-defined permission set. - Showcase declares `showcase_member_default` (isDefault) — a read-mostly baseline for fresh sign-ups, so `pnpm dev` demonstrates an app-declared default posture instead of the built-in wildcard. Proofs: unit test for `appDefaultProfileName`, and a showcase dogfood test that wires the extracted name as the fallback and shows a fresh member is governed by the declared default (reads announcements, denied private notes the built-in wildcard would have allowed). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- examples/app-showcase/src/security/index.ts | 29 ++++++++- packages/cli/src/commands/serve.ts | 10 ++- ...howcase-d7-default-profile.dogfood.test.ts | 61 +++++++++++++++++++ .../src/app-default-profile.test.ts | 31 ++++++++++ .../src/app-default-profile.ts | 36 +++++++++++ packages/plugins/plugin-security/src/index.ts | 1 + 6 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts create mode 100644 packages/plugins/plugin-security/src/app-default-profile.test.ts create mode 100644 packages/plugins/plugin-security/src/app-default-profile.ts diff --git a/examples/app-showcase/src/security/index.ts b/examples/app-showcase/src/security/index.ts index 0ce2403f0b..144e57ed62 100644 --- a/examples/app-showcase/src/security/index.ts +++ b/examples/app-showcase/src/security/index.ts @@ -83,6 +83,33 @@ export const ContributorPermissionSet = { ], }; +// ── App-declared DEFAULT PROFILE (ADR-0056 D7) ────────────────────────────── +/** + * The showcase's default access posture for a freshly signed-up user who holds + * no explicit grants. `isDefault: true` makes the app declare what "a new member + * can do" instead of inheriting the built-in `member_default` wildcard. The CLI + * (`pnpm dev`) reads this off the stack and wires it as the SecurityPlugin + * fallback (ADR-0056 D7) — without that wiring an `isDefault` flag in app + * metadata is silently ignored. Deliberately read-mostly: a brand-new member can + * browse the shared catalog + announcements and file tasks/inquiries, but cannot + * edit or delete anyone's records (owner/OWD enforcement still applies on top). + */ +export const MemberDefaultProfile = { + name: 'showcase_member_default', + label: 'Showcase Member (Default)', + description: 'App-declared default profile for new sign-ups — read-mostly baseline (ADR-0056 D7).', + isProfile: true, + isDefault: true, + objects: { + showcase_account: { allowRead: true }, + showcase_product: { allowRead: true }, + showcase_project: { allowRead: true }, + showcase_task: { allowRead: true, allowCreate: true }, + showcase_announcement: { allowRead: true }, + showcase_inquiry: { allowRead: true, allowCreate: true }, + }, +}; + // ── Sharing rules ────────────────────────────────────────────────────────── /** criteria-based: red-health projects are shared up to executives. */ export const RedProjectSharingRule = { @@ -128,6 +155,6 @@ export const ShowcasePolicy = { }; export const allRoles = [ContributorRole, ManagerRole, ExecRole]; -export const allPermissionSets = [ContributorPermissionSet]; +export const allPermissionSets = [ContributorPermissionSet, MemberDefaultProfile]; export const allSharingRules = [RedProjectSharingRule, ContributorTaskSharingRule]; export const allPolicies = [ShowcasePolicy]; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 2fa349099c..a4404ff760 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1297,8 +1297,14 @@ export default class Serve extends Command { // Pair: SecurityPlugin (RBAC) — optional try { const securityPkg = '@objectstack/plugin-security'; - const { SecurityPlugin } = await import(/* webpackIgnore: true */ securityPkg); - await kernel.use(new SecurityPlugin()); + const { SecurityPlugin, appDefaultProfileName } = await import(/* webpackIgnore: true */ securityPkg); + // ADR-0056 D7 — honor an app-declared default profile. A stack + // permission set marked `isProfile && isDefault` becomes the + // fallback for users with no explicit grants. The SecurityPlugin's + // own scan only sees its built-in sets, so the CLI passes the + // declared name through explicitly (undefined → built-in default). + const appDefaultProfile = appDefaultProfileName((config as any)?.permissions); + await kernel.use(new SecurityPlugin(appDefaultProfile ? { fallbackPermissionSet: appDefaultProfile } : undefined)); trackPlugin('Security'); } catch { // optional diff --git a/packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts b/packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts new file mode 100644 index 0000000000..a51f65d1ee --- /dev/null +++ b/packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// SHOWCASE proof for ADR-0056 D7 — the app-declared default profile, wired the +// way the CLI wires it. The showcase declares `showcase_member_default` with +// `isDefault: true`; `appDefaultProfileName(stack.permissions)` (the helper the +// CLI calls) extracts its name, and passing it as the SecurityPlugin +// `fallbackPermissionSet` makes a fresh sign-up governed by THAT profile instead +// of the built-in `member_default` wildcard. Read-mostly default ⇒ the member +// can read announcements but is DENIED the private-note object (which the +// wildcard would have allowed) — proving the app's declared default is in force. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, securityDefaultPermissionSets, appDefaultProfileName } from '@objectstack/plugin-security'; +import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; + +// Mirror the CLI: pull the app-declared default profile (name + object) off the +// stack metadata via the same helper the CLI uses. +const stackPerms = ((showcaseStack as { permissions?: unknown[] }).permissions ?? []) as Array<{ name?: string }>; +const appDefault = appDefaultProfileName(stackPerms); +const declaredDefault = stackPerms.find((p) => p?.name === appDefault) as unknown; + +describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () => { + let stack: VerifyStack; + let memberToken: string; + + beforeAll(async () => { + // The full CLI boot loads stack permission sets into the metadata service, so + // `fallbackPermissionSet: ` resolves there. The lightweight harness does + // not seed permission metadata, so we hand the declared default to the plugin + // directly — then wire it by NAME exactly as the CLI's appDefaultProfileName + // path does (constructor uses the explicit name, not its own isDefault scan). + stack = await bootStack(showcaseStack, { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, PermissionSetSchema.parse(declaredDefault) as PermissionSet], + fallbackPermissionSet: appDefault, + }), + }); + await stack.signIn(); + memberToken = await stack.signUp('d7-showcase-member@verify.test'); + }, 60_000); + + afterAll(async () => { await stack?.stop(); }); + + it('appDefaultProfileName extracts the showcase default profile from stack metadata', () => { + expect(appDefault).toBe('showcase_member_default'); + }); + + it('a fresh member is governed by the app-declared default (reads announcements)', async () => { + const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_announcement'); + expect(r.status, 'declared default grants announcement read').toBe(200); + }); + + it('and NOT by the built-in member_default wildcard (private_note is denied)', async () => { + const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_private_note'); + // member_default has a wildcard grant → would be 200. The app default grants + // no private_note access → denied, proving the declared default is in force. + expect(r.status, 'declared default does NOT grant private_note').not.toBe(200); + }); +}); diff --git a/packages/plugins/plugin-security/src/app-default-profile.test.ts b/packages/plugins/plugin-security/src/app-default-profile.test.ts new file mode 100644 index 0000000000..17fa70cfc1 --- /dev/null +++ b/packages/plugins/plugin-security/src/app-default-profile.test.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { describe, it, expect } from 'vitest'; +import { appDefaultProfileName } from './app-default-profile.js'; + +describe('appDefaultProfileName (ADR-0056 D7)', () => { + it('returns the name of the first isProfile+isDefault permission set', () => { + const perms = [ + { name: 'add_on', isProfile: false, isDefault: true }, // not a profile → skipped + { name: 'member', isProfile: true }, // not default → skipped + { name: 'app_default', isProfile: true, isDefault: true }, + { name: 'second_default', isProfile: true, isDefault: true }, + ]; + expect(appDefaultProfileName(perms)).toBe('app_default'); + }); + + it('treats a profile with no explicit isProfile flag as a profile', () => { + expect(appDefaultProfileName([{ name: 'd', isDefault: true }])).toBe('d'); + }); + + it('returns undefined when no default profile is declared', () => { + expect(appDefaultProfileName([{ name: 'a', isProfile: true }])).toBeUndefined(); + expect(appDefaultProfileName([])).toBeUndefined(); + expect(appDefaultProfileName(undefined)).toBeUndefined(); + expect(appDefaultProfileName(null)).toBeUndefined(); + expect(appDefaultProfileName('nope')).toBeUndefined(); + }); + + it('ignores a default flag on a non-profile add-on permission set', () => { + expect(appDefaultProfileName([{ name: 'addon', isProfile: false, isDefault: true }])).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-security/src/app-default-profile.ts b/packages/plugins/plugin-security/src/app-default-profile.ts new file mode 100644 index 0000000000..cf791f87fb --- /dev/null +++ b/packages/plugins/plugin-security/src/app-default-profile.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0056 D7 — resolve the app-declared default profile NAME from a stack's + * `permissions[]` array. + * + * A permission set marked `isProfile && isDefault` declares the app's default + * access posture for users with no explicit grants. The {@link SecurityPlugin} + * constructor scans its `defaultPermissionSets` option for that flag — but the + * CLI constructs `new SecurityPlugin()` with NO options, so an `isDefault` + * profile declared purely in app METADATA would never be honored. The CLI calls + * this helper to pull the name out of the stack and pass it as + * `fallbackPermissionSet`, wiring the declaration through to `pnpm dev`. + * + * Returns the first matching profile's `name`, or `undefined` when none is + * declared (callers then keep the built-in `member_default` fallback). + */ +export function appDefaultProfileName(permissions: unknown): string | undefined { + if (!Array.isArray(permissions)) return undefined; + for (const p of permissions) { + if (p && typeof p === 'object') { + const ps = p as { name?: unknown; isProfile?: unknown; isDefault?: unknown }; + // `isProfile !== false` mirrors the stack convention where profiles double + // as the user's baseline; permission-set add-ons set `isProfile: false`. + if ( + ps.isDefault === true && + ps.isProfile !== false && + typeof ps.name === 'string' && + ps.name.length > 0 + ) { + return ps.name; + } + } + } + return undefined; +} diff --git a/packages/plugins/plugin-security/src/index.ts b/packages/plugins/plugin-security/src/index.ts index 8690cf2679..18af17308d 100644 --- a/packages/plugins/plugin-security/src/index.ts +++ b/packages/plugins/plugin-security/src/index.ts @@ -25,3 +25,4 @@ export { } from './auto-org-admin-grant.js'; export { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; export { claimSeedOwnership } from './claim-seed-ownership.js'; +export { appDefaultProfileName } from './app-default-profile.js'; From 1e1b95f4f96719cd0708083c971361b90ee405c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 11:36:17 +0000 Subject: [PATCH 3/5] chore: add changeset for ADR-0056 Option A + D7 --- .changeset/adr-0056-showcase-optiona-d7.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/adr-0056-showcase-optiona-d7.md diff --git a/.changeset/adr-0056-showcase-optiona-d7.md b/.changeset/adr-0056-showcase-optiona-d7.md new file mode 100644 index 0000000000..3bd48caf4f --- /dev/null +++ b/.changeset/adr-0056-showcase-optiona-d7.md @@ -0,0 +1,20 @@ +--- +"@objectstack/plugin-security": minor +"@objectstack/cli": patch +"@objectstack/example-showcase": patch +--- + +feat(security): public-form demo (Option A) + app-declared default profile wiring (ADR-0056 D7) + +Wires ADR-0056's app-declarable default profile through the CLI so it actually +takes effect under `pnpm dev`. `@objectstack/plugin-security` exports a new +`appDefaultProfileName(permissions)` helper that extracts the first +`isProfile && isDefault` profile name from a stack; `@objectstack/cli` (`serve.ts`) +passes it as the SecurityPlugin `fallbackPermissionSet` (undefined → built-in +`member_default` preserved, so apps that declare no default are unaffected). + +The showcase gains a working web-to-lead **public form** (`showcase_inquiry` + +an `allowAnonymous` FormView authorized by the declaration-derived +`publicFormGrant`, no `guest_portal` profile) and an app-declared default +profile (`showcase_member_default`), each covered by a dogfood proof over the +real HTTP stack. From 1a0bbba608e6aad27f8bd571c4bb57f94075063e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 11:44:39 +0000 Subject: [PATCH 4/5] fix(app-showcase): drop unsupported name/object/label keys from inquiry view defineView infers the object from list.data; FormView has no label field. These tripped the example-apps typecheck (TS2353). --- examples/app-showcase/src/views/inquiry.view.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/app-showcase/src/views/inquiry.view.ts b/examples/app-showcase/src/views/inquiry.view.ts index 653c061372..91b2c10980 100644 --- a/examples/app-showcase/src/views/inquiry.view.ts +++ b/examples/app-showcase/src/views/inquiry.view.ts @@ -21,8 +21,6 @@ const data = { provider: 'object' as const, object: 'showcase_inquiry' }; * hook (`hooks/index.ts`). */ export const InquiryViews = defineView({ - name: 'showcase_inquiry_views', - object: 'showcase_inquiry', // Default list shown when the object is opened — carries `data` so the view // registrar can resolve the target object (without it the whole view, public // form included, is dropped). @@ -55,7 +53,6 @@ export const InquiryViews = defineView({ // "what the public may set"; everything else is stripped server-side. contact: { type: 'simple', - label: 'Contact Us', data, sections: [ { From 4664850bcb2e386fafec41b728d06aa8ee27d538 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 11:51:21 +0000 Subject: [PATCH 5/5] fix(app-showcase): stamp inquiry source only when absent (verify fidelity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest-defaults hook unconditionally set source='web', so the verify round-trip wrote source='verify-sample' and read back 'web' — a fidelity gap that failed the Dogfood Regression Gate. Make it a default (only when absent), matching status. Public submissions never include source (the form whitelist excludes it), so they still get 'web'; explicit values now round-trip cleanly. --- examples/app-showcase/src/hooks/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/app-showcase/src/hooks/index.ts b/examples/app-showcase/src/hooks/index.ts index c096a5d9df..68a16be3c9 100644 --- a/examples/app-showcase/src/hooks/index.ts +++ b/examples/app-showcase/src/hooks/index.ts @@ -94,7 +94,7 @@ export const StampInquiryDefaultsHook = { events: ['beforeInsert'] as LifecycleEvent[], body: { language: 'js' as const, - source: "if (!ctx.input.status) ctx.input.status = 'new'; ctx.input.source = 'web';", + source: "if (!ctx.input.status) ctx.input.status = 'new'; if (!ctx.input.source) ctx.input.source = 'web';", }, priority: 50, onError: 'abort' as const,