Skip to content

Commit 5a5a9fe

Browse files
os-zhuangclaude
andauthored
feat(app-showcase): ADR-0056 Option A public form + D7 default-profile CLI wiring (#2076)
* feat(app-showcase): web-to-lead public form demonstrating ADR-0056 Option A 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx * feat(security): wire app-declared default profile through the CLI (ADR-0056 D7) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx * chore: add changeset for ADR-0056 Option A + D7 * 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). * fix(app-showcase): stamp inquiry source only when absent (verify fidelity) 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. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 751f5cf commit 5a5a9fe

14 files changed

Lines changed: 412 additions & 5 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/plugin-security": minor
3+
"@objectstack/cli": patch
4+
"@objectstack/example-showcase": patch
5+
---
6+
7+
feat(security): public-form demo (Option A) + app-declared default profile wiring (ADR-0056 D7)
8+
9+
Wires ADR-0056's app-declarable default profile through the CLI so it actually
10+
takes effect under `pnpm dev`. `@objectstack/plugin-security` exports a new
11+
`appDefaultProfileName(permissions)` helper that extracts the first
12+
`isProfile && isDefault` profile name from a stack; `@objectstack/cli` (`serve.ts`)
13+
passes it as the SecurityPlugin `fallbackPermissionSet` (undefined → built-in
14+
`member_default` preserved, so apps that declare no default are unaffected).
15+
16+
The showcase gains a working web-to-lead **public form** (`showcase_inquiry` +
17+
an `allowAnonymous` FormView authorized by the declaration-derived
18+
`publicFormGrant`, no `guest_portal` profile) and an app-declared default
19+
profile (`showcase_member_default`), each covered by a dogfood proof over the
20+
real HTTP stack.

examples/app-showcase/objectstack.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from '@objectstack/cloud-connection';
1313

1414
import * as objects from './src/objects/index.js';
15-
import { TaskViews, ProjectViews } from './src/views/index.js';
15+
import { TaskViews, ProjectViews, InquiryViews } from './src/views/index.js';
1616
import { ShowcaseApp } from './src/apps/index.js';
1717
import { ChartGalleryDashboard, OpsDashboard } from './src/dashboards/index.js';
1818
import { ShowcaseTaskDataset, ShowcaseProjectDataset } from './src/datasets/index.js';
@@ -142,7 +142,7 @@ export default defineStack({
142142
// UI
143143
apps: [ShowcaseApp],
144144
portals: allPortals,
145-
views: [TaskViews, ProjectViews],
145+
views: [TaskViews, ProjectViews, InquiryViews],
146146
pages: [ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, AccountDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage],
147147
dashboards: [ChartGalleryDashboard, OpsDashboard],
148148
books: allBooks,

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,32 @@ export const WarnOverBudgetHook = {
7878
description: 'Emits a warning when a project’s spend exceeds its budget.',
7979
};
8080

81+
/**
82+
* beforeInsert — stamp server-controlled defaults on a public inquiry.
83+
*
84+
* The web-to-lead public form (ADR-0056 Option A) lets anonymous visitors
85+
* INSERT a `showcase_inquiry`. Its field whitelist already excludes `status` /
86+
* `source`, but this hook is the server-side belt-and-braces: it stamps
87+
* `status = 'new'` and `source = 'web'` so an inquiry can never arrive
88+
* pre-triaged, regardless of how the request was crafted.
89+
*/
90+
export const StampInquiryDefaultsHook = {
91+
name: 'showcase_stamp_inquiry_defaults',
92+
label: 'Stamp Inquiry Defaults',
93+
object: 'showcase_inquiry',
94+
events: ['beforeInsert'] as LifecycleEvent[],
95+
body: {
96+
language: 'js' as const,
97+
source: "if (!ctx.input.status) ctx.input.status = 'new'; if (!ctx.input.source) ctx.input.source = 'web';",
98+
},
99+
priority: 50,
100+
onError: 'abort' as const,
101+
description: 'Stamps status=new and source=web on every new inquiry (public web-to-lead defaults).',
102+
};
103+
81104
export const allHooks = [
82105
NormalizeTaskTitleHook,
106+
StampInquiryDefaultsHook,
83107
AuditTaskCompletionHook,
84108
WarnOverBudgetHook,
85109
];

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export { FieldZoo } from './field-zoo.object.js';
1010
export { Preference } from './preference.object.js';
1111
export { PrivateNote } from './private-note.object.js';
1212
export { Announcement } from './announcement.object.js';
13+
export { Inquiry } from './inquiry.object.js';
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* Inquiry — the inbox behind the PUBLIC FORM (ADR-0056 Option A).
7+
*
8+
* This is the Salesforce *Web-to-Lead* target: a "Contact Us / Request a Demo"
9+
* form is exposed to anonymous visitors (see `views/inquiry.view.ts`,
10+
* `sharing.allowAnonymous: true`). The submit route DERIVES authorization from
11+
* the form's own declaration — a narrow `publicFormGrant: { object:
12+
* 'showcase_inquiry' }` — so an anonymous POST can create exactly one inquiry
13+
* (and read it back) and nothing else, with NO `guest_portal` profile required
14+
* and even under secure-by-default (`requireAuth`). The grant is create +
15+
* read-back only; everything authenticated staff need (triage, status changes)
16+
* comes from a normal permission set.
17+
*
18+
* `sharingModel: 'private'` keeps inquiries owner/staff-scoped once inside —
19+
* the public path only ever inserts, never lists.
20+
*/
21+
export const Inquiry = ObjectSchema.create({
22+
name: 'showcase_inquiry',
23+
label: 'Inquiry',
24+
pluralLabel: 'Inquiries',
25+
icon: 'mail',
26+
description: 'A public contact-form submission — created anonymously via the web-to-lead public form (ADR-0056 Option A).',
27+
28+
// Once inside, an inquiry is staff-only. The public form does not read the
29+
// list; it only inserts + reads back the row it just created.
30+
sharingModel: 'private',
31+
32+
fields: {
33+
name: Field.text({ label: 'Name', required: true, searchable: true, maxLength: 120 }),
34+
email: Field.email({ label: 'Email', required: true, searchable: true }),
35+
company: Field.text({ label: 'Company', maxLength: 120 }),
36+
message: Field.text({ label: 'Message', required: true, maxLength: 2000 }),
37+
// Server-controlled — anonymous submitters can never set these (the form
38+
// whitelist excludes them and the guest-defaults hook stamps/strips them).
39+
status: Field.select({
40+
label: 'Status',
41+
options: [
42+
{ label: 'New', value: 'new', default: true, color: '#3B82F6' },
43+
{ label: 'Contacted', value: 'contacted', color: '#F59E0B' },
44+
{ label: 'Closed', value: 'closed', color: '#10B981' },
45+
],
46+
}),
47+
source: Field.text({ label: 'Source', maxLength: 40 }),
48+
},
49+
});

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,33 @@ export const ContributorPermissionSet = {
8383
],
8484
};
8585

86+
// ── App-declared DEFAULT PROFILE (ADR-0056 D7) ──────────────────────────────
87+
/**
88+
* The showcase's default access posture for a freshly signed-up user who holds
89+
* no explicit grants. `isDefault: true` makes the app declare what "a new member
90+
* can do" instead of inheriting the built-in `member_default` wildcard. The CLI
91+
* (`pnpm dev`) reads this off the stack and wires it as the SecurityPlugin
92+
* fallback (ADR-0056 D7) — without that wiring an `isDefault` flag in app
93+
* metadata is silently ignored. Deliberately read-mostly: a brand-new member can
94+
* browse the shared catalog + announcements and file tasks/inquiries, but cannot
95+
* edit or delete anyone's records (owner/OWD enforcement still applies on top).
96+
*/
97+
export const MemberDefaultProfile = {
98+
name: 'showcase_member_default',
99+
label: 'Showcase Member (Default)',
100+
description: 'App-declared default profile for new sign-ups — read-mostly baseline (ADR-0056 D7).',
101+
isProfile: true,
102+
isDefault: true,
103+
objects: {
104+
showcase_account: { allowRead: true },
105+
showcase_product: { allowRead: true },
106+
showcase_project: { allowRead: true },
107+
showcase_task: { allowRead: true, allowCreate: true },
108+
showcase_announcement: { allowRead: true },
109+
showcase_inquiry: { allowRead: true, allowCreate: true },
110+
},
111+
};
112+
86113
// ── Sharing rules ──────────────────────────────────────────────────────────
87114
/** criteria-based: red-health projects are shared up to executives. */
88115
export const RedProjectSharingRule = {
@@ -128,6 +155,6 @@ export const ShowcasePolicy = {
128155
};
129156

130157
export const allRoles = [ContributorRole, ManagerRole, ExecRole];
131-
export const allPermissionSets = [ContributorPermissionSet];
158+
export const allPermissionSets = [ContributorPermissionSet, MemberDefaultProfile];
132159
export const allSharingRules = [RedProjectSharingRule, ContributorTaskSharingRule];
133160
export const allPolicies = [ShowcasePolicy];

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22

33
export { TaskViews } from './task.view.js';
44
export { ProjectViews } from './project.view.js';
5+
export { InquiryViews } from './inquiry.view.js';
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { defineView } from '@objectstack/spec';
4+
5+
const data = { provider: 'object' as const, object: 'showcase_inquiry' };
6+
7+
/**
8+
* Inquiry views — including the PUBLIC web-to-lead form (ADR-0056 Option A).
9+
*
10+
* `formViews.contact` declares `sharing.allowAnonymous: true` + a `publicLink`
11+
* slug, which wires the anonymous REST endpoints automatically:
12+
*
13+
* GET /api/v1/forms/contact-us → resolved form + whitelisted schema
14+
* POST /api/v1/forms/contact-us/submit → INSERT a showcase_inquiry
15+
*
16+
* The submit route DERIVES authorization from this declaration — a narrow
17+
* `publicFormGrant: { object: 'showcase_inquiry' }` — so it works even though
18+
* the showcase boots with secure-by-default auth, and WITHOUT any
19+
* `guest_portal` profile. Only the `sections[].fields` below are accepted on
20+
* submit; `status` / `source` are stamped server-side by the guest-defaults
21+
* hook (`hooks/index.ts`).
22+
*/
23+
export const InquiryViews = defineView({
24+
// Default list shown when the object is opened — carries `data` so the view
25+
// registrar can resolve the target object (without it the whole view, public
26+
// form included, is dropped).
27+
list: {
28+
label: 'Inquiries',
29+
type: 'grid',
30+
data,
31+
columns: [
32+
{ field: 'name' },
33+
{ field: 'email' },
34+
{ field: 'company' },
35+
{ field: 'status' },
36+
],
37+
},
38+
listViews: {
39+
triage: {
40+
type: 'grid',
41+
label: 'Inquiry Triage',
42+
data,
43+
columns: [
44+
{ field: 'name' },
45+
{ field: 'email' },
46+
{ field: 'company' },
47+
{ field: 'status' },
48+
],
49+
},
50+
},
51+
formViews: {
52+
// PUBLIC — anonymous web-to-lead. The whitelist below is the authoritative
53+
// "what the public may set"; everything else is stripped server-side.
54+
contact: {
55+
type: 'simple',
56+
data,
57+
sections: [
58+
{
59+
label: 'Tell us about yourself',
60+
columns: 1,
61+
fields: [
62+
{ field: 'name', required: true },
63+
{ field: 'email', required: true },
64+
{ field: 'company' },
65+
{ field: 'message', required: true },
66+
],
67+
},
68+
],
69+
sharing: {
70+
enabled: true,
71+
allowAnonymous: true,
72+
publicLink: '/forms/contact-us',
73+
},
74+
submitBehavior: {
75+
kind: 'thank-you',
76+
title: 'Thanks!',
77+
message: 'We received your message and a specialist will reach out shortly.',
78+
},
79+
},
80+
},
81+
});

packages/cli/src/commands/serve.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,8 +1297,14 @@ export default class Serve extends Command {
12971297
// Pair: SecurityPlugin (RBAC) — optional
12981298
try {
12991299
const securityPkg = '@objectstack/plugin-security';
1300-
const { SecurityPlugin } = await import(/* webpackIgnore: true */ securityPkg);
1301-
await kernel.use(new SecurityPlugin());
1300+
const { SecurityPlugin, appDefaultProfileName } = await import(/* webpackIgnore: true */ securityPkg);
1301+
// ADR-0056 D7 — honor an app-declared default profile. A stack
1302+
// permission set marked `isProfile && isDefault` becomes the
1303+
// fallback for users with no explicit grants. The SecurityPlugin's
1304+
// own scan only sees its built-in sets, so the CLI passes the
1305+
// declared name through explicitly (undefined → built-in default).
1306+
const appDefaultProfile = appDefaultProfileName((config as any)?.permissions);
1307+
await kernel.use(new SecurityPlugin(appDefaultProfile ? { fallbackPermissionSet: appDefaultProfile } : undefined));
13021308
trackPlugin('Security');
13031309
} catch {
13041310
// optional
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// SHOWCASE proof for ADR-0056 D7 — the app-declared default profile, wired the
4+
// way the CLI wires it. The showcase declares `showcase_member_default` with
5+
// `isDefault: true`; `appDefaultProfileName(stack.permissions)` (the helper the
6+
// CLI calls) extracts its name, and passing it as the SecurityPlugin
7+
// `fallbackPermissionSet` makes a fresh sign-up governed by THAT profile instead
8+
// of the built-in `member_default` wildcard. Read-mostly default ⇒ the member
9+
// can read announcements but is DENIED the private-note object (which the
10+
// wildcard would have allowed) — proving the app's declared default is in force.
11+
12+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
13+
import showcaseStack from '@objectstack/example-showcase';
14+
import { bootStack, type VerifyStack } from '@objectstack/verify';
15+
import { SecurityPlugin, securityDefaultPermissionSets, appDefaultProfileName } from '@objectstack/plugin-security';
16+
import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security';
17+
18+
// Mirror the CLI: pull the app-declared default profile (name + object) off the
19+
// stack metadata via the same helper the CLI uses.
20+
const stackPerms = ((showcaseStack as { permissions?: unknown[] }).permissions ?? []) as Array<{ name?: string }>;
21+
const appDefault = appDefaultProfileName(stackPerms);
22+
const declaredDefault = stackPerms.find((p) => p?.name === appDefault) as unknown;
23+
24+
describe('showcase: app-declared default profile, CLI-wired (ADR-0056 D7)', () => {
25+
let stack: VerifyStack;
26+
let memberToken: string;
27+
28+
beforeAll(async () => {
29+
// The full CLI boot loads stack permission sets into the metadata service, so
30+
// `fallbackPermissionSet: <name>` resolves there. The lightweight harness does
31+
// not seed permission metadata, so we hand the declared default to the plugin
32+
// directly — then wire it by NAME exactly as the CLI's appDefaultProfileName
33+
// path does (constructor uses the explicit name, not its own isDefault scan).
34+
stack = await bootStack(showcaseStack, {
35+
security: new SecurityPlugin({
36+
defaultPermissionSets: [...securityDefaultPermissionSets, PermissionSetSchema.parse(declaredDefault) as PermissionSet],
37+
fallbackPermissionSet: appDefault,
38+
}),
39+
});
40+
await stack.signIn();
41+
memberToken = await stack.signUp('d7-showcase-member@verify.test');
42+
}, 60_000);
43+
44+
afterAll(async () => { await stack?.stop(); });
45+
46+
it('appDefaultProfileName extracts the showcase default profile from stack metadata', () => {
47+
expect(appDefault).toBe('showcase_member_default');
48+
});
49+
50+
it('a fresh member is governed by the app-declared default (reads announcements)', async () => {
51+
const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_announcement');
52+
expect(r.status, 'declared default grants announcement read').toBe(200);
53+
});
54+
55+
it('and NOT by the built-in member_default wildcard (private_note is denied)', async () => {
56+
const r = await stack.apiAs(memberToken, 'GET', '/data/showcase_private_note');
57+
// member_default has a wildcard grant → would be 200. The app default grants
58+
// no private_note access → denied, proving the declared default is in force.
59+
expect(r.status, 'declared default does NOT grant private_note').not.toBe(200);
60+
});
61+
});

0 commit comments

Comments
 (0)