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
3 changes: 2 additions & 1 deletion examples/app-crm/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
SalesManagerRole,
FinanceApproverRole,
SalesUserPermissionSet,
GuestPortalProfile,
HighValueOpportunitySharingRule,
RepLeadSharingRule,
WonDealActivitySharingRule,
Expand Down Expand Up @@ -116,7 +117,7 @@ export default defineStack({

// Security
roles: [SalesRepRole, SalesManagerRole, FinanceApproverRole],
permissions: [SalesUserPermissionSet],
permissions: [SalesUserPermissionSet, GuestPortalProfile],
sharingRules: [
HighValueOpportunitySharingRule,
RepLeadSharingRule,
Expand Down
4 changes: 4 additions & 0 deletions examples/app-crm/src/objects/lead.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
17 changes: 5 additions & 12 deletions examples/app-crm/src/portals/customer.portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
1 change: 1 addition & 0 deletions examples/app-crm/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
SalesManagerRole,
FinanceApproverRole,
SalesUserPermissionSet,
GuestPortalProfile,
} from './sales-roles.js';

export {
Expand Down
18 changes: 18 additions & 0 deletions examples/app-crm/src/security/sales-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
};
32 changes: 32 additions & 0 deletions examples/app-crm/src/views/lead.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
37 changes: 37 additions & 0 deletions packages/spec/src/stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions packages/spec/src/stack.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }).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<string>();
Expand Down
Loading