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/adr-0056-showcase-optiona-d7.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions examples/app-showcase/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'; if (!ctx.input.source) 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,
];
1 change: 1 addition & 0 deletions examples/app-showcase/src/objects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
49 changes: 49 additions & 0 deletions examples/app-showcase/src/objects/inquiry.object.ts
Original file line number Diff line number Diff line change
@@ -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 }),
},
});
29 changes: 28 additions & 1 deletion examples/app-showcase/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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];
1 change: 1 addition & 0 deletions examples/app-showcase/src/views/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

export { TaskViews } from './task.view.js';
export { ProjectViews } from './project.view.js';
export { InquiryViews } from './inquiry.view.js';
81 changes: 81 additions & 0 deletions examples/app-showcase/src/views/inquiry.view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// 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({
// 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',
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.',
},
},
},
});
10 changes: 8 additions & 2 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions packages/dogfood/test/showcase-d7-default-profile.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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: <name>` 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);
});
});
Loading
Loading