Skip to content

Commit 034b867

Browse files
committed
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
1 parent 751f5cf commit 034b867

7 files changed

Lines changed: 230 additions & 2 deletions

File tree

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'; 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/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: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
name: 'showcase_inquiry_views',
25+
object: 'showcase_inquiry',
26+
// Default list shown when the object is opened — carries `data` so the view
27+
// registrar can resolve the target object (without it the whole view, public
28+
// form included, is dropped).
29+
list: {
30+
label: 'Inquiries',
31+
type: 'grid',
32+
data,
33+
columns: [
34+
{ field: 'name' },
35+
{ field: 'email' },
36+
{ field: 'company' },
37+
{ field: 'status' },
38+
],
39+
},
40+
listViews: {
41+
triage: {
42+
type: 'grid',
43+
label: 'Inquiry Triage',
44+
data,
45+
columns: [
46+
{ field: 'name' },
47+
{ field: 'email' },
48+
{ field: 'company' },
49+
{ field: 'status' },
50+
],
51+
},
52+
},
53+
formViews: {
54+
// PUBLIC — anonymous web-to-lead. The whitelist below is the authoritative
55+
// "what the public may set"; everything else is stripped server-side.
56+
contact: {
57+
type: 'simple',
58+
label: 'Contact Us',
59+
data,
60+
sections: [
61+
{
62+
label: 'Tell us about yourself',
63+
columns: 1,
64+
fields: [
65+
{ field: 'name', required: true },
66+
{ field: 'email', required: true },
67+
{ field: 'company' },
68+
{ field: 'message', required: true },
69+
],
70+
},
71+
],
72+
sharing: {
73+
enabled: true,
74+
allowAnonymous: true,
75+
publicLink: '/forms/contact-us',
76+
},
77+
submitBehavior: {
78+
kind: 'thank-you',
79+
title: 'Thanks!',
80+
message: 'We received your message and a specialist will reach out shortly.',
81+
},
82+
},
83+
},
84+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// SHOWCASE proof for ADR-0056 Option A — the `showcase_inquiry` web-to-lead
4+
// PUBLIC FORM. `views/inquiry.view.ts` declares a FormView with
5+
// `sharing.allowAnonymous: true` + `publicLink: '/forms/contact-us'`, which
6+
// wires the anonymous REST endpoints. This exercises them end-to-end over the
7+
// real HTTP stack — the harness boots with `requireAuth: true`, so a passing
8+
// anonymous submit proves the route works under SECURE-BY-DEFAULT auth, with
9+
// NO `guest_portal` profile, authorized solely by the declaration-derived
10+
// `publicFormGrant` (create + read-back on `showcase_inquiry` ONLY).
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 } from '@objectstack/plugin-security';
16+
17+
describe('showcase: web-to-lead public form (ADR-0056 Option A)', () => {
18+
let stack: VerifyStack;
19+
20+
beforeAll(async () => {
21+
stack = await bootStack(showcaseStack, {
22+
security: new SecurityPlugin({
23+
defaultPermissionSets: [...securityDefaultPermissionSets],
24+
}),
25+
});
26+
}, 60_000);
27+
28+
afterAll(async () => {
29+
await stack?.stop();
30+
});
31+
32+
it('GET /forms/:slug returns the form + a whitelisted schema (no auth)', async () => {
33+
const r = await stack.api('/forms/contact-us');
34+
expect(r.status, 'anonymous form resolve must succeed').toBe(200);
35+
const body = (await r.json()) as { object: string; form: unknown; objectSchema: { fields: Record<string, unknown> } };
36+
expect(body.object).toBe('showcase_inquiry');
37+
// Only the whitelisted form fields are exposed — server-controlled fields are absent.
38+
const fields = Object.keys(body.objectSchema.fields);
39+
expect(fields.sort()).toEqual(['company', 'email', 'message', 'name']);
40+
expect(fields).not.toContain('status');
41+
expect(fields).not.toContain('source');
42+
});
43+
44+
it('POST /forms/:slug/submit creates an inquiry anonymously under requireAuth (Option A)', async () => {
45+
const r = await stack.api('/forms/contact-us/submit', {
46+
method: 'POST',
47+
headers: { 'content-type': 'application/json' },
48+
body: JSON.stringify({
49+
name: 'Ada Lovelace',
50+
email: 'ada@example.com',
51+
company: 'Analytical Engines Ltd',
52+
message: 'Please contact me about a pilot.',
53+
status: 'closed', // ← not in whitelist → stripped; hook stamps 'new'
54+
}),
55+
});
56+
expect(r.status, 'anonymous submit must succeed under requireAuth=true').toBe(201);
57+
const body = (await r.json()) as { object: string; id: string; record: Record<string, unknown> };
58+
expect(body.object).toBe('showcase_inquiry');
59+
expect(body.record.name).toBe('Ada Lovelace');
60+
// Server-controlled: whitelist stripped the client `status`, the hook stamped defaults.
61+
expect(body.record.status, 'status is server-stamped, not client-set').toBe('new');
62+
expect(body.record.source).toBe('web');
63+
});
64+
65+
it('the public grant is create + read-back ONLY — anonymous cannot list inquiries', async () => {
66+
const r = await stack.api('/data/showcase_inquiry');
67+
expect(r.status, 'general anonymous read must NOT be opened by the form grant').not.toBe(200);
68+
});
69+
});

0 commit comments

Comments
 (0)