Skip to content

Commit 37c7114

Browse files
test(verify): pin the anonymous public-form door a booted stack already serves (#22543)
Part of #22301 Clause-②: no ## What this is Item 4 of #22301, the anonymous public-form door on the verify handle. Items 1-3 landed earlier, items 5-9 are not addressed here, and #22301 remains open. **I checked the premise first, and it does not hold on this base.** A booted stack already serves `POST /api/v1/forms/:slug/submit` anonymously, through the stack's own HTTP surface. The item therefore reduces to pins and one documentation pointer. Nothing changes in `packages/rest`, `packages/runtime` or `packages/objectql`, and the handle gains no method. ## Premise reading (base `40a6ee50a`, re-checked after merging `faf634850`) A fixture app declares one public form (`sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/prm-intake' }`) and is booted with `bootStack`: | request | answer | |---|---| | `stack.api('/forms/prm-intake/submit', POST)`, no token | `201` `{"id":"XP3f15bln6eNEnGH"}` | | the handle's in-process `HttpDispatcher.dispatch('POST', '/forms/prm-intake/submit', ...)` | `404` `ROUTE_NOT_FOUND` | | `stack.api('/api/v1/forms/prm-intake/submit', ...)` (prefix twice) | `404` `ENDPOINT_NOT_FOUND` | | `stack.raw('/forms/prm-intake/submit', ...)` (no prefix) | `404` `ENDPOINT_NOT_FOUND` | | `stack.api('/forms/nope/submit', ...)` | `404` `FORM_NOT_FOUND` | | `stack.api('/data/prm_request', POST)`, no token | `401` `UNAUTHENTICATED` | - **The route has one owner.** `RestServer.registerFormEndpoints` registers it (`packages/rest/src/rest-server.ts:10563-10565` at the base). `bootStack` mounts that server through `createRestApiPlugin` (`packages/verify/src/harness.ts:1063`) onto the stack's Hono app, so `stack.api` reaches the route. - **The handle's in-process dispatcher does not serve `/forms`, and should not.** That dispatcher drives `flows.*` and `actions.run`. A second implementation of the form route would fork its invariants (AGENTS.md, Route and surface ownership, rule 1). - **Where `ENDPOINT_NOT_FOUND` comes from.** It is the Hono app's not-found answer (`plugin-hono-server/src/adapter.ts:1219`). On this base, the only requests that get it are paths that miss the route, like the two spellings above. That is the likely origin of the 17.7.0 reading on the card. - I did not run 17.7.0 itself (NOT MEASURED). However, at the `@objectstack/verify@17.7.0` tag commit `4e4e88142`, `packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts` already submits through `bootStack` and `stack.api('/forms/contact-us/submit')`. - **What the engine receives.** A middleware on the engine recorded exactly `{ publicFormGrant: { object: 'prm_request' }, permissions: ['guest_portal'], anonymous: true }`. That is the context the route builds at `rest-server.ts:10664-10668`. ## What changes - **`packages/verify/src/handle.public-form-door.test.ts` (new).** Seven pins, all through a booted stack and the route's own path (`stack.api('/forms/:slug/submit')`, no token): 1. An anonymous submit answers `201` with the created id, and the row lands with what was submitted. 2. The engine receives the door's own execution context, exactly. The pin reads the context through the engine's own `registerMiddleware` seam and does not re-spell it: `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }`, with no user, no system principal, and nothing else. 3. The bound hook sees a guest: `session` and `user` are both undefined, so an app's guest branch runs (here it stamps `origin: 'web'`). The control is a person's insert through `hooks.run`, where the hook sees that person and stamps `'internal'`. 4. The record-change trigger fires the object's `record-after-create` flow with no trigger user, and the flow reaches its write. The control is a person's insert, whose run carries that person. 5. A key the form does not collect (`status`) never reaches the engine. The engine is handed only the payload keys `['email', 'subject']`. 6. Refusal control: an unknown slug gets `404` `FORM_NOT_FOUND`. The engine is never handed a write, no hook runs, and no row lands. 7. Refusal control: a form that is shared but not anonymous (`allowAnonymous: false`, with a `publicLink`) gets the same answer as 6, and nothing reaches the engine. - **`packages/verify/src/handle.ts`.** Documentation only: - The header's door roster now names the anonymous form door and says why it is not a handle method. - `hooks.run`'s JSDoc tells a reader that an anonymous write is the form's own route, `api('/forms/:slug/submit', ...)` with no token. The hotcrm port reported "the handle offers no way to reach that branch at all", so this is the pointer it was missing. - **`.changeset/22301-verify-public-form-door.md`.** `@objectstack/verify` `patch`, `Clause-②: no`. The JSDoc ships: after `pnpm --filter @objectstack/verify build`, `packages/verify/dist/index.d.ts` carries the new phrase once, with the positive control (an existing JSDoc phrase, "address the row by") also present once. The test file ships nothing (`pfd_request` and `public-form-door` have 0 hits in `dist/index.js` and `dist/index.d.ts`). So this needs a changeset, not `skip-changeset`. ## Ablation: one-time proof through the built `@objectstack/rest` dist `@objectstack/verify` resolves `@objectstack/rest` through its `exports` (to `dist/`), with no source alias. So each leg went: mutate with `scripts/ablation-replace.mjs` (WRAP mode, trap-restored), run `pnpm --filter @objectstack/rest build`, run `scripts/ablation-dist-preflight.mjs` (marker in `dist/`), then run the pin file. - **Leg A: the route's context becomes a system write.** The mutation is `anonymous: true,` changed to `isSystem: true, anonymous: Boolean('ablation-22301-system'),`. The anchor went 1 to 0 and the blob went `e1a572a19260` to `dd6fcbf0550a`, with the marker in dist. Result: **2 failed, 5 passed.** - Pin 2 failed: `expected { …(4) } to deeply equal { …(3) }`, with `+ "isSystem": true`. - Pin 3 failed: `expected { userId: undefined, …(4) } to be undefined`, because the hook received `session: { isSystem: true, … }`. - The others stayed green, as predicted. - **Leg B: the field whitelist admits a key the form does not collect.** The mutation is `if (allowedFields.has(k)) filteredData[k] = v;` changed to `if (allowedFields.has(k) || (k === 'status' && 'ablation-22301-whitelist')) filteredData[k] = v;`. Result: **1 failed, 6 passed.** Pin 5 failed: `the payload the engine was handed: expected [ [ 'email', 'status', 'subject' ] ] to deeply equal [ [ 'email', 'subject' ] ]`. - **Restore leg.** - Both legs restored the source: the blob equals HEAD `e1a572a19260` and `git diff HEAD` is empty. - I then rebuilt `@objectstack/rest`. `ablation-dist-preflight --absent` passes for both markers ("marker absent from all 6 built files", "working tree clean against HEAD"). - The pin file passes 7 of 7. - The first attempt at leg A was a no-op. Its replacement contained the anchor, so `ablation-replace` refused it ("the anchor count moved 1 -> 1") and restored the file. Leg A above is the corrected run. - The refusal pins (6, 7) were not ablated. Their subject is `anonymousFormIntakeCandidates` in `@objectstack/metadata-core`, which is outside this diff's surface. Each pin also asserts that the engine was never handed a write. ## Tests and gates These results are from the final head **`5bf0b5ab5`**, which merges `origin/main` `faf634850` into the two commits above. The build state was refreshed after the merge (`pnpm install --frozen-lockfile`, then `pnpm --filter '@objectstack/verify...' build`, the package plus its dependency closure). - `pnpm --filter @objectstack/verify exec vitest run --maxWorkers=2`: **Test Files 25 passed (25), Tests 203 passed (203)**. This is the package in full: 25 test files on disk, the new one included. The same suite at the pre-merge head `19465fae3` also passed 25/25 files and 203/203 tests. - `pnpm --filter @objectstack/verify typecheck`: `tsc --noEmit` exit 0, and `check:test-typecheck: OK — @objectstack/verify's test layer compiles under packages/verify/tsconfig.test.json; 0 file(s) / 0 error(s)`. The new test file is in that program: it is listed once by `tsc --noEmit -p tsconfig.test.json --listFiles`. - The new pin file on its own: 7 passed (7). - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 63 commands at `5bf0b5ab5`. All 63 were run, each exit code captured before any pipe, and every one is 0. `--ran` printed: `Run reconciliation — 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN.` Some of those gates' own lines: - `check-nul-bytes: OK (scanned 10524 text file(s) ...; no raw ASCII control bytes)` - `✓ doc authoring guard: 17989 customer-facing string(s) ... clean` - `check-test-source-alias OK — 73 packages with tests scanned` - `✓ check:published-files — 69 publishable package(s) ...` - `check:cross-package-test-inputs OK: 30 package(s) read outside themselves, all declared` - At the pre-merge head, `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (some packages had no `dist/`). Once those were built, it was re-run green (`107 published require entry point(s) across 66 package(s) load`), and it is green in the final-head run. - **Lint, narrowed and proved.** `pnpm lint` is CI's to run. I ran it on the two touched TypeScript files only: `eslint --no-inline-config --format json packages/verify/src/handle.ts packages/verify/src/handle.public-form-door.test.ts` returned **2 files linted, 0 errors, 0 warnings**, exit 0. Why the narrowing excludes nothing: - Both files are in the population `eslint.config.mjs` declares (`files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` and `packages/**/*.{ts,tsx,mts,cts}`). - The config never enables type-aware linting: no `parserOptions.project` and no `projectService` (0 hits), and its own docblock at lines 327-329 says the same. So this diff cannot change any untouched file's lint verdict. - **CI:** read on the PR head after opening (see the report on #22301). ## For hotcrm (the `repo:hotcrm` seat) hotcrm's matching local path is `guestInsert` in `test/helpers/verify-stack.ts:239-242`, read read-only at hotcrm `f0afcbd`. It has 20 call sites in 11 test files. hotcrm declares two public forms, `/forms/contact-us` (`crm_lead`, `web_to_lead`) and `/forms/support` (`crm_case`, `web_to_case`). The door replaces that helper. Three differences matter when porting a call site: 1. **It takes a slug, not an object.** 2. **It answers `{ id }`.** Read the row back with `stack.rows(object, { id })`. 3. **It keeps only the fields the form collects.** - Call sites whose keys are all collected map one-for-one (for example `case-assignment.test.ts:339`). - Call sites that pass uncollected keys model a form that does not exist. Examples: `status` in `flow-sla-ownerless-case.test.ts:104`, `rating` in `flow-record-change.test.ts:610`, and the guest-strip fixtures in `hooks-runtime-service.test.ts:132` / `:1083`. Through the real door those keys are dropped before any hook runs. Such a case either drops the key, or (for a hook-level strip test) states that the route's whitelist is now what it observes. None of this needs a platform change. The hotcrm suite was not run. ## Acceptance notes (not filed) - **The engine's `buildSession` docblock says more than holds.** It says "Every real transport resolves `positions` into the context, so an anonymous HTTP request still yields a session and stays gated." The public-form door is a real transport, and it yields no session (measured: the hook's `session` and `user` are both undefined). So a hook cannot tell a public-form submission from a bare programmatic call by `session` alone. A guest branch keyed on "no user and no system principal" (the hotcrm shape, and pin 3's) works. - I found no platform hook that gates on a missing session (`git grep` over `packages/**` for `!ctx.session` and similar spellings: 0 hook hits). - Read-only observation, so it is not filed. Carrier: none. - **The README sentence "There is no way to run as \"nobody\"" (`packages/verify/README.md`) is about `as` on handle methods, and it is accurate for them.** A pointer to the form door there would help a reader. The README is outside this item's claimed file surface, so it is left for the seat. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent 3d886ee commit 37c7114

3 files changed

Lines changed: 382 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/verify': patch
3+
---
4+
5+
docs(verify): `hooks.run` names the anonymous public-form door a booted stack already serves
6+
7+
Clause-②: no
8+
9+
An app's web-to-lead or web-to-case branch runs on an anonymous public-form submission, and a booted stack already serves that write: it is the form's own route, `api('/forms/:slug/submit', { method: 'POST', body })` with no token. `bootStack` mounts that route, and the in-process handle has no method for it, by design: its one owner is `@objectstack/rest`. Nothing changes in what the package does. `hooks.run`'s documentation now names the door, so a suite reaches it instead of calling the engine with a hand-built guest context.
10+
11+
What the door does, as the package's own tests now pin it on a booted stack:
12+
13+
- It hands the engine the route's own context: the form's one-object grant, the `guest_portal` permission set, and `anonymous`. There is no user and no system principal.
14+
- A bound hook sees no session and no user, so an app's guest branch runs, and the record-change trigger fires the object's flows with no trigger user.
15+
- It keeps only the fields the form collects and answers `201` with `{ id }`. Read the row back with `rows(object, { id })`.
16+
- An unknown slug, or a form that is shared but not anonymous, is answered `404` with `code: 'FORM_NOT_FOUND'`, and nothing is written.
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22301 item 4 — the anonymous public-form door, reached through a booted
5+
* stack.
6+
*
7+
* An app's web-to-lead / web-to-case branch runs on exactly one kind of write:
8+
* an anonymous public-form submission, `POST /api/v1/forms/:slug/submit`. That
9+
* route has ONE owner, `RestServer.registerFormEndpoints` in `@objectstack/rest`,
10+
* which `bootStack` mounts on the stack's Hono app. So on a booted stack the
11+
* door is `api('/forms/:slug/submit', …)` with no token, and it is the
12+
* platform's own route end to end: the slug resolution, the form's field
13+
* whitelist, the execution context it hands the engine, the `{ id }` answer.
14+
* The handle's in-process dispatcher (`flows.*`, `actions.run`) does not serve
15+
* it, and must not: a second implementation would be a copy of the route's
16+
* invariants that its owner never sees. A suite that rebuilds the door's
17+
* execution context by hand and calls the engine with it is that copy.
18+
*
19+
* What is pinned, each against what only the real door produces:
20+
* - an anonymous submit succeeds, and the row lands;
21+
* - the engine is handed the door's own execution context, exactly as the
22+
* route builds it (`publicFormGrant` for the form's object, the
23+
* `guest_portal` set, `anonymous`), read off the engine's own middleware
24+
* seam rather than re-spelled;
25+
* - the bound hook sees a guest (no session, no user), so an app's guest
26+
* branch runs; a person's write through `hooks.run` is the control;
27+
* - the record-change trigger fires the object's flow with no trigger user;
28+
* - a key the form does not collect never reaches the engine;
29+
* - refusals: an unknown slug, and a form that is not public, are answered
30+
* `404 FORM_NOT_FOUND` and the engine is never handed a write.
31+
*/
32+
33+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
34+
35+
import { defineStack, defineView } from '@objectstack/spec';
36+
import { ObjectSchema, Field } from '@objectstack/spec/data';
37+
import type { Flow } from '@objectstack/spec/automation';
38+
import { PermissionSetSchema } from '@objectstack/spec/security';
39+
import type { ObjectQL, OperationContext } from '@objectstack/objectql';
40+
41+
import { bootStack, type VerifyStack } from './harness.js';
42+
import type { EngineRow } from './handle.js';
43+
44+
// Booting the full in-process stack runs well past vitest's 5s default.
45+
const BOOT_TIMEOUT = 120_000;
46+
47+
const REQUEST = 'pfd_request';
48+
const LEDGER = 'pfd_ledger';
49+
const ON_CREATE = 'pfd_request_created';
50+
const PUBLIC_SLUG = 'pfd-intake';
51+
const STAFF_SLUG = 'pfd-staff';
52+
53+
/** One dispatch the bound hook chain received, keyed by the submitted subject. */
54+
interface HookSeen {
55+
event: string;
56+
subject: unknown;
57+
session: unknown;
58+
user: unknown;
59+
keys: string[];
60+
}
61+
const hookSaw: HookSeen[] = [];
62+
63+
/** One insert the engine was handed for the form's object: its context and payload keys. */
64+
interface EngineSaw {
65+
subject: unknown;
66+
context: unknown;
67+
keys: string[];
68+
}
69+
const engineSaw: EngineSaw[] = [];
70+
71+
/** A copy taken when the engine is handed it; a value that cannot be copied is kept as is. */
72+
function snapshot<T>(value: T): T {
73+
try {
74+
return structuredClone(value);
75+
} catch {
76+
return value;
77+
}
78+
}
79+
80+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
81+
type HookCtx ={ event: string; input: Record<string, any>; session?: Record<string, any>; user?: { id?: string } };
82+
83+
/**
84+
* The app's guest branch, the shape a web-to-lead / web-to-case hook takes: a
85+
* write with no user and no system principal is a guest's, and the hook
86+
* stamps what a guest cannot send. It records what it saw first.
87+
*/
88+
const guestBranch = async (ctx: HookCtx) => {
89+
hookSaw.push({
90+
event: ctx.event,
91+
subject: ctx.input?.subject,
92+
session: ctx.session,
93+
user: ctx.user,
94+
keys: Object.keys(ctx.input ?? {}).sort(),
95+
});
96+
if (ctx.event === 'beforeInsert') {
97+
ctx.input.origin = !ctx.user?.id && ctx.session?.isSystem !== true ? 'web' : 'internal';
98+
}
99+
};
100+
101+
const Request = ObjectSchema.create({
102+
name: REQUEST,
103+
label: 'Request',
104+
pluralLabel: 'Requests',
105+
sharingModel: 'private',
106+
fields: {
107+
subject: Field.text({ label: 'Subject', required: true }),
108+
email: Field.text({ label: 'Email' }),
109+
// Neither form collects these two: the hook stamps one, nobody may send the other.
110+
origin: Field.text({ label: 'Origin' }),
111+
status: Field.text({ label: 'Status' }),
112+
},
113+
});
114+
115+
/** What the record-change flow writes: one row per run that reached its write. */
116+
const Ledger = ObjectSchema.create({
117+
name: LEDGER,
118+
label: 'Ledger',
119+
pluralLabel: 'Ledgers',
120+
sharingModel: 'public_read_write',
121+
fields: {
122+
name: Field.text({ label: 'Name', required: true }),
123+
record_id: Field.text({ label: 'Record' }),
124+
},
125+
});
126+
127+
const data = { provider: 'object' as const, object: REQUEST };
128+
const RequestViews = defineView({
129+
list: { label: 'Requests', type: 'grid', data, columns: [{ field: 'subject' }] },
130+
formViews: {
131+
// Open to anonymous intake: enabled, anonymous, a public link.
132+
intake: {
133+
type: 'simple',
134+
data,
135+
sections: [
136+
{ name: 'intake', label: 'Intake', columns: 1, fields: [{ field: 'subject', required: true }, { field: 'email' }] },
137+
],
138+
sharing: { enabled: true, allowAnonymous: true, publicLink: `/forms/${PUBLIC_SLUG}` },
139+
},
140+
// Shared, with a link, but NOT anonymous: not a public form.
141+
staff: {
142+
type: 'simple',
143+
data,
144+
sections: [{ name: 'staff', label: 'Staff', columns: 1, fields: [{ field: 'subject', required: true }] }],
145+
sharing: { enabled: true, allowAnonymous: false, publicLink: `/forms/${STAFF_SLUG}` },
146+
},
147+
},
148+
});
149+
150+
/** start (record-after-create) → create_record(pfd_ledger) → end. */
151+
const onCreate = {
152+
name: ON_CREATE,
153+
label: ON_CREATE,
154+
type: 'autolaunched',
155+
status: 'active',
156+
// The ledger is the flow's witness, written whoever (or nobody) triggered it.
157+
runAs: 'system',
158+
nodes: [
159+
{ id: 'start', type: 'start', label: 'On create', config: { objectName: REQUEST, triggerType: 'record-after-create' } },
160+
{
161+
id: 'write',
162+
type: 'create_record',
163+
label: 'Ledger',
164+
config: { objectName: LEDGER, fields: { name: ON_CREATE, record_id: { dialect: 'cel', source: 'record.id' } } },
165+
},
166+
{ id: 'end', type: 'end', label: 'End' },
167+
],
168+
edges: [
169+
{ id: 'e1', source: 'start', target: 'write' },
170+
{ id: 'e2', source: 'write', target: 'end' },
171+
],
172+
} as Flow;
173+
174+
const memberSet = PermissionSetSchema.parse({
175+
name: 'pfd_member_default',
176+
label: 'Public form door fixture member (default)',
177+
isDefault: true,
178+
objects: { [REQUEST]: { allowRead: true, allowCreate: true } },
179+
});
180+
181+
const fixtureStack = defineStack({
182+
manifest: {
183+
id: 'com.objectstack.verify.public-form-door',
184+
namespace: 'pfd',
185+
version: '0.0.0',
186+
type: 'app',
187+
name: 'Verify Public Form Door Fixture',
188+
description: 'One public form and one staff-only form on a request object, a guest-branch hook and a record-change flow.',
189+
},
190+
// ADR-0097: a record-change start node needs both capabilities.
191+
requires: ['automation', 'triggers'],
192+
objects: [Request, Ledger],
193+
views: [RequestViews],
194+
hooks: [{ name: 'pfd_guest_branch', object: REQUEST, events: ['beforeInsert', 'afterInsert'], handler: guestBranch }],
195+
flows: [onCreate],
196+
permissions: [memberSet],
197+
} as never);
198+
199+
let stack: VerifyStack;
200+
let member: string;
201+
let memberId: string;
202+
203+
beforeAll(async () => {
204+
stack = await bootStack(fixtureStack);
205+
// The first user is the seeded dev admin, so this sign-up is a plain member.
206+
member = await stack.signUp('pfd-member@verify.test');
207+
memberId = String((await stack.contextFor(member)).userId);
208+
// The engine's own extension seam, innermost: what each insert on the form's
209+
// object was handed, after every gate the boot registered has run.
210+
const ql = await stack.kernel.getServiceAsync<ObjectQL>('objectql');
211+
ql.registerMiddleware(
212+
async (op: OperationContext, next) => {
213+
if (op.operation === 'insert') {
214+
const row = (Array.isArray(op.data) ? op.data[0] : op.data) ?? {};
215+
engineSaw.push({ subject: row.subject, context: snapshot(op.context), keys: Object.keys(row).sort() });
216+
}
217+
await next();
218+
},
219+
{ object: REQUEST },
220+
);
221+
}, BOOT_TIMEOUT);
222+
223+
afterAll(async () => {
224+
await stack?.stop().catch(() => undefined);
225+
});
226+
227+
/** Unique per call, so no assertion sees another test's rows. */
228+
const uniq = (prefix: string): string => `${prefix}-${Math.random().toString(36).slice(2, 8)}`;
229+
230+
/** The anonymous door: no token, the route's own path on the stack's HTTP surface. */
231+
async function submit(slug: string, body: EngineRow): Promise<{ status: number; body: EngineRow }> {
232+
const res = await stack.api(`/forms/${slug}/submit`, {
233+
method: 'POST',
234+
headers: { 'content-type': 'application/json' },
235+
body: JSON.stringify(body),
236+
});
237+
const wire = await res.text();
238+
return { status: res.status, body: JSON.parse(wire) as EngineRow };
239+
}
240+
241+
const hooksFor = (subject: string): HookSeen[] => hookSaw.filter((h) => h.subject === subject);
242+
const engineFor = (subject: string): EngineSaw[] => engineSaw.filter((e) => e.subject === subject);
243+
244+
/** The engine's run log for the on-create flow, narrowed to runs `recordId` triggered. */
245+
async function runsFor(recordId: string): Promise<Array<{ status: string; trigger: Record<string, unknown> }>> {
246+
const automation = stack.kernel.getService('automation') as {
247+
listRuns(name: string): Promise<Array<{ status: string; trigger?: Record<string, unknown> }>>;
248+
};
249+
return (await automation.listRuns(ON_CREATE))
250+
.filter((r) => r.trigger?.recordId === recordId)
251+
.map((r) => ({ status: r.status, trigger: r.trigger ?? {} }));
252+
}
253+
254+
describe('item 4 — the anonymous public-form door on a booted stack', () => {
255+
it('an anonymous submit succeeds: 201 with the created id, and the row lands with what was submitted', async () => {
256+
const subject = uniq('anon');
257+
const res = await submit(PUBLIC_SLUG, { subject, email: 'guest@verify.test' });
258+
259+
expect(res.status, JSON.stringify(res.body)).toBe(201);
260+
expect(typeof res.body.id, 'the created id, at the top level').toBe('string');
261+
const [row] = await stack.rows(REQUEST, { id: res.body.id });
262+
expect(row).toMatchObject({ subject, email: 'guest@verify.test' });
263+
});
264+
265+
it("the engine is handed the door's own execution context, exactly as the route builds it", async () => {
266+
const subject = uniq('ctx');
267+
const res = await submit(PUBLIC_SLUG, { subject });
268+
expect(res.status).toBe(201);
269+
270+
const seen = engineFor(subject);
271+
expect(seen, 'one insert reached the engine').toHaveLength(1);
272+
// The route's context, whole: the form's one-object grant, the guest set,
273+
// anonymous. No user, no system principal, nothing else.
274+
expect(seen[0].context).toEqual({
275+
publicFormGrant: { object: REQUEST },
276+
permissions: ['guest_portal'],
277+
anonymous: true,
278+
});
279+
});
280+
281+
it("the bound hook sees a guest, so the app's guest branch runs; a person's write is the control", async () => {
282+
const guestSubject = uniq('guest');
283+
const res = await submit(PUBLIC_SLUG, { subject: guestSubject });
284+
expect(res.status).toBe(201);
285+
286+
const guest = hooksFor(guestSubject);
287+
expect(guest.map((h) => h.event)).toEqual(['beforeInsert', 'afterInsert']);
288+
for (const h of guest) {
289+
// No identity envelope reaches the hook: no session, no user.
290+
expect(h.session).toBeUndefined();
291+
expect(h.user).toBeUndefined();
292+
}
293+
expect((await stack.rows(REQUEST, { id: res.body.id }))[0].origin, 'the guest branch stamped the row').toBe('web');
294+
295+
// Control that DISCRIMINATES: the same object written by a person through
296+
// the handle's write door reaches the same hook carrying that person.
297+
const personSubject = uniq('person');
298+
const written = await stack.hooks.run(REQUEST, 'insert', { subject: personSubject }, { as: member });
299+
const person = hooksFor(personSubject);
300+
expect(person.map((h) => h.event)).toEqual(['beforeInsert', 'afterInsert']);
301+
for (const h of person) expect(h.user).toMatchObject({ id: memberId });
302+
expect((await stack.rows(REQUEST, { id: written.id }))[0].origin).toBe('internal');
303+
});
304+
305+
it('the record-change trigger fires the flow on an anonymous submit, with no trigger user', async () => {
306+
const res = await submit(PUBLIC_SLUG, { subject: uniq('flow') });
307+
expect(res.status).toBe(201);
308+
const id = res.body.id as string;
309+
310+
const runs = await runsFor(id);
311+
expect(runs, 'the record-change trigger fired the flow').toHaveLength(1);
312+
expect(runs[0].status).toBe('completed');
313+
expect(runs[0].trigger).toMatchObject({ type: 'record-after-create', object: REQUEST });
314+
expect(runs[0].trigger.userId, 'no trigger user').toBeUndefined();
315+
expect(await stack.rows(LEDGER, { record_id: id }), 'the flow reached its write').toHaveLength(1);
316+
317+
// Control: a person's insert fires the same flow carrying that person.
318+
const written = await stack.hooks.run(REQUEST, 'insert', { subject: uniq('flow-person') }, { as: member });
319+
expect((await runsFor(String(written.id))).map((r) => r.trigger.userId)).toEqual([memberId]);
320+
});
321+
322+
it('a key the form does not collect never reaches the engine', async () => {
323+
const subject = uniq('extra');
324+
const res = await submit(PUBLIC_SLUG, { subject, email: 'guest@verify.test', status: 'forged' });
325+
expect(res.status).toBe(201);
326+
327+
expect(engineFor(subject).map((e) => e.keys), 'the payload the engine was handed').toEqual([['email', 'subject']]);
328+
const before = hooksFor(subject).find((h) => h.event === 'beforeInsert');
329+
expect(before?.keys, 'what the hook saw').not.toContain('status');
330+
expect((await stack.rows(REQUEST, { id: res.body.id }))[0].status).toBeNull();
331+
});
332+
333+
describe('refusals: answered by the route, and the engine is never handed a write', () => {
334+
it('an unknown slug', async () => {
335+
const subject = uniq('unknown');
336+
const res = await submit('pfd-no-such-form', { subject });
337+
expect({ status: res.status, code: res.body.code }).toEqual({ status: 404, code: 'FORM_NOT_FOUND' });
338+
expect(engineFor(subject)).toEqual([]);
339+
expect(hooksFor(subject)).toEqual([]);
340+
expect(await stack.rows(REQUEST, { subject })).toEqual([]);
341+
});
342+
343+
it('a form that is shared but not anonymous', async () => {
344+
const subject = uniq('staff');
345+
const res = await submit(STAFF_SLUG, { subject });
346+
expect({ status: res.status, code: res.body.code }).toEqual({ status: 404, code: 'FORM_NOT_FOUND' });
347+
expect(engineFor(subject)).toEqual([]);
348+
expect(hooksFor(subject)).toEqual([]);
349+
expect(await stack.rows(REQUEST, { subject })).toEqual([]);
350+
});
351+
});
352+
});

‎packages/verify/src/handle.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@
3535
// exposes no lower in-process door with the same contract. The dispatcher
3636
// is protocol-neutral by design (`HttpProtocolContext`), so driving it
3737
// directly IS the REST path minus HTTP.
38+
// the anonymous public-form door → NOT a method here. [#22301] A
39+
// public form's submission is `POST /api/v1/forms/:slug/submit`, a route
40+
// with one owner (`@objectstack/rest`'s `registerFormEndpoints`) that
41+
// `bootStack` mounts on the stack's Hono app, so on a booted stack it is
42+
// `api('/forms/:slug/submit', …)` with no token. The dispatcher above does
43+
// not serve it, and a method here that handed the engine a hand-built
44+
// `{ publicFormGrant, … }` context would be the stand-in the design rule
45+
// below forbids. `handle.public-form-door.test.ts` pins the door.
3846
// contextFor(token) → the dispatcher's own identity
3947
// resolution (`resolveRequestScope` → `resolveExecutionContext` →
4048
// `@objectstack/core`'s `resolveAuthzContext`), the exact resolver every
@@ -156,6 +164,12 @@ export interface VerifyHandle {
156164
* the message alone.
157165
*
158166
* `update` and `delete` address the row by `input.id`.
167+
*
168+
* An ANONYMOUS write, a public-form submission (the write a web-to-lead
169+
* or web-to-case branch runs on), is not this door: it is the form's own
170+
* route on the stack, `api('/forms/:slug/submit', { method: 'POST',
171+
* body })` with no token. That route hands the engine its own guest
172+
* context, keeps only the fields the form collects, and answers `{ id }`.
159173
*/
160174
run(
161175
object: string,

0 commit comments

Comments
 (0)