From be796ada27a09700cf17f7ed80eb3e4dda909a4b Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 17 Jun 2026 09:43:39 +0800 Subject: [PATCH 1/2] feat(crm): working Web-to-Lead public form example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app-crm example showed Web-to-Lead only via `customer.portal` `anonymousEntry` — a spec property with NO runtime consumer, so it never worked (404). Add the mechanism that actually does, and make the example coherent: - lead.view.ts: `web_to_lead` form view with `sharing.allowAnonymous` → live `GET/POST /api/v1/forms/contact-us` (+ /submit). - sales-roles.ts: `GuestPortalProfile` (isProfile, INSERT-only on crm_lead, keyed by FULL object name — the anonymous permission path requires it). - lead.object.ts: `status` gets `defaultValue: 'new'` so a minimal public create satisfies `required` (the option-level `default` is only a UI preselect). - customer.portal.ts: drop the dead `anonymousEntry` routes; point to the working form view instead (re-add when the runtime mounts anonymousEntry). - security/index.ts + objectstack.config.ts: export + register the guest profile. Doubles as a CI regression test for the public-form path (builds against the workspace) — the #1989 flattened-metadata resolution bug would have been caught here. Verified end-to-end (app-crm, no auth): GET /forms/contact-us → 200 form spec; POST /forms/contact-us/submit → creates a crm_lead (status=new); GET /data/crm_lead → 401 (guests can't read). Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/app-crm/objectstack.config.ts | 3 +- examples/app-crm/src/objects/lead.object.ts | 4 +++ .../app-crm/src/portals/customer.portal.ts | 17 +++------- examples/app-crm/src/security/index.ts | 1 + examples/app-crm/src/security/sales-roles.ts | 18 +++++++++++ examples/app-crm/src/views/lead.view.ts | 32 +++++++++++++++++++ 6 files changed, 62 insertions(+), 13 deletions(-) diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index 3db5d16733..e8220f84fe 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -23,6 +23,7 @@ import { SalesManagerRole, FinanceApproverRole, SalesUserPermissionSet, + GuestPortalProfile, HighValueOpportunitySharingRule, RepLeadSharingRule, WonDealActivitySharingRule, @@ -116,7 +117,7 @@ export default defineStack({ // Security roles: [SalesRepRole, SalesManagerRole, FinanceApproverRole], - permissions: [SalesUserPermissionSet], + permissions: [SalesUserPermissionSet, GuestPortalProfile], sharingRules: [ HighValueOpportunitySharingRule, RepLeadSharingRule, diff --git a/examples/app-crm/src/objects/lead.object.ts b/examples/app-crm/src/objects/lead.object.ts index bf69288e19..58d23e2e76 100644 --- a/examples/app-crm/src/objects/lead.object.ts +++ b/examples/app-crm/src/objects/lead.object.ts @@ -36,6 +36,10 @@ export const Lead = ObjectSchema.create({ status: Field.select({ label: 'Status', required: true, + // Write-path default so minimal creates (e.g. the public Web-to-Lead form, + // which doesn't expose status) satisfy `required` — the option `default` + // below is only a UI preselect. + defaultValue: 'new', options: [ { label: 'New', value: 'new', default: true, color: '#94A3B8' }, { label: 'Contacted', value: 'contacted', color: '#3B82F6' }, diff --git a/examples/app-crm/src/portals/customer.portal.ts b/examples/app-crm/src/portals/customer.portal.ts index 202d08ed6c..639a726a46 100644 --- a/examples/app-crm/src/portals/customer.portal.ts +++ b/examples/app-crm/src/portals/customer.portal.ts @@ -51,18 +51,11 @@ export const CustomerPortal: Portal = { target: '_blank', }, ], - anonymousEntry: { - routes: [ - { - path: '/contact', - actionRef: 'crm_lead.create', - rateLimit: { rule: '5/hour/ip', scope: 'ip' }, - captcha: true, - bindIdentityFromField: 'email', - }, - ], - defaultRateLimit: { rule: '100/day/ip', scope: 'ip' }, - }, + // NOTE: `anonymousEntry` is a spec property with no runtime consumer yet — the + // routes here would never mount (verified: 404). For a WORKING public capture + // form, see the `web_to_lead` form view in `views/lead.view.ts` + // (`sharing.allowAnonymous` → live `/api/v1/forms/contact-us` endpoint). Re-add + // `anonymousEntry` here only once the runtime mounts it. defaultRoute: { viewRef: 'crm_activity.activity_grid', }, diff --git a/examples/app-crm/src/security/index.ts b/examples/app-crm/src/security/index.ts index b1ba09e017..adf35a6800 100644 --- a/examples/app-crm/src/security/index.ts +++ b/examples/app-crm/src/security/index.ts @@ -5,6 +5,7 @@ export { SalesManagerRole, FinanceApproverRole, SalesUserPermissionSet, + GuestPortalProfile, } from './sales-roles.js'; export { diff --git a/examples/app-crm/src/security/sales-roles.ts b/examples/app-crm/src/security/sales-roles.ts index 3c2c35e038..d684a12005 100644 --- a/examples/app-crm/src/security/sales-roles.ts +++ b/examples/app-crm/src/security/sales-roles.ts @@ -43,3 +43,21 @@ export const SalesUserPermissionSet: Security.PermissionSet = { crm_activity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, }, }; + +/** + * Guest profile for the public Web-to-Lead form (lead.view.ts `web_to_lead`). + * + * Applied to anonymous (unauthenticated) visitors who POST the public form. The + * anonymous permission path checks the FULL object name, so this MUST key + * `crm_lead` (not a short `lead`). INSERT-only — guests can never read, edit, or + * delete any record. + */ +export const GuestPortalProfile: Security.PermissionSet = { + name: 'guest_portal', + label: 'Guest (Public Forms)', + description: 'Anonymous Web-to-Lead submitters — INSERT-only on crm_lead.', + isProfile: true, + objects: { + crm_lead: { allowRead: false, allowCreate: true, allowEdit: false, allowDelete: false }, + }, +}; diff --git a/examples/app-crm/src/views/lead.view.ts b/examples/app-crm/src/views/lead.view.ts index 0d118c5b69..c566eb937a 100644 --- a/examples/app-crm/src/views/lead.view.ts +++ b/examples/app-crm/src/views/lead.view.ts @@ -45,6 +45,38 @@ export const LeadViews = defineView({ }, }, formViews: { + /** + * PUBLIC / ANONYMOUS — Web-to-Lead. + * + * Hosted at GET/POST `/api/v1/forms/contact-us` (+ `/submit`). An + * unauthenticated visitor — or an `EmbeddableForm` iframe on a marketing + * site — submits it to create a `crm_lead`. `sharing.allowAnonymous` opens + * the public endpoint; the `guest_portal` profile (INSERT-only on crm_lead) + * authorizes the write. The lead object's own defaults/hooks stamp internal + * fields (status, owner, score). + */ + web_to_lead: { + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [ + { + label: 'Contact us', + columns: 1, + fields: [ + { field: 'name', required: true }, + { field: 'company' }, + { field: 'email', required: true }, + { field: 'phone' }, + { field: 'title' }, + ], + }, + ], + sharing: { + enabled: true, + allowAnonymous: true, + publicLink: '/forms/contact-us', + }, + }, default: { type: 'simple', sections: [ From 592fdecfdf94ceb660d5858a5b56df03ed2e916b Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 17 Jun 2026 10:19:49 +0800 Subject: [PATCH 2/2] fix(spec): cross-validate permission/profile object grants against declared objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `validateCrossReferences` checked hook/view/seed/app-nav/action references against the declared object set — but NOT permission-set/profile `objects` grants, even though `validateNamespacePrefix`'s doc already assumed it did. So a profile that grants on a non-existent object (e.g. a short `lead` instead of the namespaced `crm_lead`) passed build/validate/test silently. The grant then applies to nothing: the authenticated path may namespace-resolve the short name, but the anonymous / explicit-permission-set path does not — so e.g. a public Web-to-Lead INSERT is denied for "roles []", with no diagnostic anywhere. Add the missing check: every `permissions[].objects` key must reference a declared object, or strict validation fails loudly at build time. - stack.zod.ts: validate permission/profile object grants → object references - stack.test.ts: +2 cases (short/undefined name fails; full name passes) 82/82 spec stack tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/spec/src/stack.test.ts | 37 +++++++++++++++++++++++++++++++++ packages/spec/src/stack.zod.ts | 23 ++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index 10e2db4ebb..5b61b3e4d8 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -485,6 +485,43 @@ describe('defineStack', () => { expect(() => defineStack(config, { strict: true })).not.toThrow(); }); + it('should detect permission/profile granting on an undefined object in strict mode', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'crm_lead', fields: { email: { type: 'email' } } }, + ], + permissions: [ + { + name: 'guest_portal', + isProfile: true, + // BUG: the object is `crm_lead`, not the short `lead` — the short name + // exists nowhere, so this grant silently applies to nothing (and the + // anonymous permission path denies the write). Must fail loudly. + objects: { lead: { allowCreate: true } }, + }, + ], + }; + expect(() => defineStack(config, { strict: true })).toThrow("object 'lead'"); + }); + + it('should pass strict mode when a permission grants on a defined (full-name) object', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'crm_lead', fields: { email: { type: 'email' } } }, + ], + permissions: [ + { + name: 'guest_portal', + isProfile: true, + objects: { crm_lead: { allowCreate: true } }, + }, + ], + }; + expect(() => defineStack(config, { strict: true })).not.toThrow(); + }); + it('should skip cross-reference validation when no objects are defined', () => { const config = { manifest: baseManifest, diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 09c2f9fd83..0ec445e099 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -583,6 +583,29 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { } } + // Validate permission-set / profile object grants → object references. + // A grant keyed by an object that isn't declared (e.g. a short `lead` instead + // of the namespaced `crm_lead`) silently applies to NOTHING: the + // authenticated path may namespace-resolve it, but the anonymous / + // explicit-permission-set path does not — so the grant is simply lost (e.g. a + // public Web-to-Lead INSERT is denied for "roles []"). Fail loudly at build + // time. (`validateNamespacePrefix`'s doc already assumes this check lives here.) + if (config.permissions) { + for (const perm of config.permissions) { + const grants = (perm as { objects?: Record }).objects; + if (grants && typeof grants === 'object') { + for (const objName of Object.keys(grants)) { + if (!objectNames.has(objName)) { + errors.push( + `Permission '${(perm as { name?: string }).name ?? '(unnamed)'}' grants on object ` + + `'${objName}' which is not defined in objects.`, + ); + } + } + } + } + } + // Validate app navigation → object/dashboard/page/report references if (config.apps) { const dashboardNames = new Set();