Skip to content

Commit 94f2651

Browse files
Copilothotlong
andcommitted
Add SystemIdentifierSchema and update core schemas to enforce lowercase naming
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent df1dffb commit 94f2651

18 files changed

Lines changed: 562 additions & 28 deletions

packages/spec/src/auth/role.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ describe('RoleSchema', () => {
1313
});
1414

1515
it('should enforce snake_case for role name', () => {
16-
const validNames = ['ceo', 'vp_sales', 'sales_manager', 'account_exec', '_internal'];
16+
const validNames = ['ceo', 'vp_sales', 'sales_manager', 'account_exec'];
1717
validNames.forEach(name => {
1818
expect(() => RoleSchema.parse({ name, label: 'Test' })).not.toThrow();
1919
});
2020

21-
const invalidNames = ['CEO', 'VP-Sales', 'salesManager', '123role'];
21+
const invalidNames = ['CEO', 'VP-Sales', 'salesManager', '123role', '_internal'];
2222
invalidNames.forEach(name => {
2323
expect(() => RoleSchema.parse({ name, label: 'Test' })).toThrow();
2424
});

packages/spec/src/auth/role.zod.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod';
2+
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
23

34
/**
45
* Role Schema (aka Business Unit / Org Unit)
@@ -11,10 +12,24 @@ import { z } from 'zod';
1112
*
1213
* ROLES IN OBJECTSTACK:
1314
* Used primarily for "Reporting Structure" - Managers see subordinates' data.
15+
*
16+
* **NAMING CONVENTION:**
17+
* Role names MUST be lowercase snake_case to prevent security issues.
18+
*
19+
* @example Good role names
20+
* - 'sales_manager'
21+
* - 'ceo'
22+
* - 'region_east_vp'
23+
* - 'engineering_lead'
24+
*
25+
* @example Bad role names (will be rejected)
26+
* - 'SalesManager' (camelCase)
27+
* - 'CEO' (uppercase)
28+
* - 'Region East VP' (spaces and uppercase)
1429
*/
1530
export const RoleSchema = z.object({
1631
/** Identity */
17-
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique role name'),
32+
name: SnakeCaseIdentifierSchema.describe('Unique role name (lowercase snake_case)'),
1833
label: z.string().describe('Display label (e.g. VP of Sales)'),
1934

2035
/** Hierarchy */

packages/spec/src/automation/webhook.zod.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod';
2+
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
23

34
/**
45
* Webhook Trigger Event
@@ -14,10 +15,22 @@ export const WebhookTriggerType = z.enum([
1415

1516
/**
1617
* Webhook Schema
17-
* outbound Integration: Push data to external URL when events happen.
18+
* Outbound Integration: Push data to external URL when events happen.
19+
*
20+
* **NAMING CONVENTION:**
21+
* Webhook names are machine identifiers and must be lowercase snake_case.
22+
*
23+
* @example Good webhook names
24+
* - 'stripe_payment_sync'
25+
* - 'slack_notification'
26+
* - 'crm_lead_export'
27+
*
28+
* @example Bad webhook names (will be rejected)
29+
* - 'StripePaymentSync' (PascalCase)
30+
* - 'slackNotification' (camelCase)
1831
*/
1932
export const WebhookSchema = z.object({
20-
name: z.string().regex(/^[a-z_][a-z0-9_]*$/),
33+
name: SnakeCaseIdentifierSchema.describe('Webhook unique name (lowercase snake_case)'),
2134
label: z.string().optional(),
2235

2336
/** Scope */
@@ -44,9 +57,20 @@ export const WebhookSchema = z.object({
4457
/**
4558
* Webhook Receiver Schema (Inbound)
4659
* Handling incoming HTTP hooks from Stripe, Slack, etc.
60+
*
61+
* **NAMING CONVENTION:**
62+
* Webhook receiver names are machine identifiers and must be lowercase snake_case.
63+
*
64+
* @example Good names
65+
* - 'stripe_webhook_handler'
66+
* - 'github_events'
67+
* - 'twilio_status_callback'
68+
*
69+
* @example Bad names (will be rejected)
70+
* - 'StripeWebhookHandler' (PascalCase)
4771
*/
4872
export const WebhookReceiverSchema = z.object({
49-
name: z.string().regex(/^[a-z_][a-z0-9_]*$/),
73+
name: SnakeCaseIdentifierSchema.describe('Webhook receiver unique name (lowercase snake_case)'),
5074
path: z.string().describe('URL Path (e.g. /webhooks/stripe)'),
5175

5276
/** Verification */

packages/spec/src/automation/workflow.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ describe('WorkflowRuleSchema', () => {
527527
});
528528

529529
it('should enforce snake_case for workflow name', () => {
530-
const validNames = ['auto_approve', 'send_notification', 'update_status', '_internal'];
530+
const validNames = ['auto_approve', 'send_notification', 'update_status'];
531531
validNames.forEach(name => {
532532
expect(() => WorkflowRuleSchema.parse({
533533
name,
@@ -536,7 +536,7 @@ describe('WorkflowRuleSchema', () => {
536536
})).not.toThrow();
537537
});
538538

539-
const invalidNames = ['autoApprove', 'Auto-Approve', '123workflow'];
539+
const invalidNames = ['autoApprove', 'Auto-Approve', '123workflow', '_internal'];
540540
invalidNames.forEach(name => {
541541
expect(() => WorkflowRuleSchema.parse({
542542
name,

packages/spec/src/automation/workflow.zod.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod';
2+
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
23

34
/**
45
* Trigger events for workflow automation
@@ -182,10 +183,24 @@ export const TimeTriggerSchema = z.object({
182183

183184
/**
184185
* Schema for Workflow Rules (Automation)
186+
*
187+
* **NAMING CONVENTION:**
188+
* Workflow names are machine identifiers and must be lowercase snake_case.
189+
*
190+
* @example Good workflow names
191+
* - 'send_welcome_email'
192+
* - 'update_lead_status'
193+
* - 'notify_manager_on_close'
194+
* - 'calculate_discount'
195+
*
196+
* @example Bad workflow names (will be rejected)
197+
* - 'SendWelcomeEmail' (PascalCase)
198+
* - 'updateLeadStatus' (camelCase)
199+
* - 'Send Welcome Email' (spaces)
185200
*/
186201
export const WorkflowRuleSchema = z.object({
187202
/** Machine name */
188-
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique workflow name'),
203+
name: SnakeCaseIdentifierSchema.describe('Unique workflow name (lowercase snake_case)'),
189204

190205
/** Target Object */
191206
objectName: z.string().describe('Target Object'),

packages/spec/src/data/field.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ describe('Field Factory Helpers', () => {
429429
expect(selectField.type).toBe('select');
430430
expect(selectField.label).toBe('Priority');
431431
expect(selectField.options).toHaveLength(3);
432-
expect(selectField.options[0]).toEqual({ label: 'High', value: 'High' });
432+
expect(selectField.options[0]).toEqual({ label: 'High', value: 'high' });
433433
});
434434

435435
it('should create select field with SelectOption array in config (new API)', () => {
@@ -461,8 +461,8 @@ describe('Field Factory Helpers', () => {
461461
expect(selectField.type).toBe('select');
462462
expect(selectField.options).toHaveLength(3);
463463
expect(selectField.options[0]).toEqual({ label: 'Active', value: 'active', color: '#00AA00' });
464-
expect(selectField.options[1]).toEqual({ label: 'Inactive', value: 'Inactive' });
465-
expect(selectField.options[2]).toEqual({ label: 'Pending', value: 'Pending' });
464+
expect(selectField.options[1]).toEqual({ label: 'Inactive', value: 'inactive' });
465+
expect(selectField.options[2]).toEqual({ label: 'Pending', value: 'pending' });
466466
});
467467
});
468468

packages/spec/src/data/field.zod.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod';
2+
import { SystemIdentifierSchema } from '../shared/identifiers.zod';
23

34
/**
45
* Field Type Enum
@@ -38,10 +39,25 @@ export type FieldType = z.infer<typeof FieldType>;
3839

3940
/**
4041
* Select Option Schema
42+
*
43+
* Defines option values for select/picklist fields.
44+
*
45+
* **CRITICAL RULE**: The `value` field is a machine identifier that gets stored in the database.
46+
* It MUST be lowercase to avoid case-sensitivity issues in queries and comparisons.
47+
*
48+
* @example Good
49+
* { label: 'New', value: 'new' }
50+
* { label: 'In Progress', value: 'in_progress' }
51+
* { label: 'Closed Won', value: 'closed_won' }
52+
*
53+
* @example Bad (will be rejected)
54+
* { label: 'New', value: 'New' } // uppercase
55+
* { label: 'In Progress', value: 'In Progress' } // spaces and uppercase
56+
* { label: 'Closed Won', value: 'Closed_Won' } // mixed case
4157
*/
4258
export const SelectOptionSchema = z.object({
43-
label: z.string().describe('Display label'),
44-
value: z.string().describe('Stored value'),
59+
label: z.string().describe('Display label (human-readable, any case allowed)'),
60+
value: SystemIdentifierSchema.describe('Stored value (lowercase machine identifier)'),
4561
color: z.string().optional().describe('Color code for badges/charts'),
4662
default: z.boolean().optional().describe('Is default option'),
4763
});
@@ -230,13 +246,28 @@ export const Field = {
230246
/**
231247
* Select field helper with backward-compatible API
232248
*
233-
* @example Old API (array first)
249+
* Automatically converts option values to lowercase to enforce naming conventions.
250+
*
251+
* @example Old API (array first) - auto-converts to lowercase
234252
* Field.select(['High', 'Low'], { label: 'Priority' })
253+
* // Results in: [{ label: 'High', value: 'high' }, { label: 'Low', value: 'low' }]
235254
*
236-
* @example New API (config object)
255+
* @example New API (config object) - enforces lowercase
237256
* Field.select({ options: [{label: 'High', value: 'high'}], label: 'Priority' })
257+
*
258+
* @example Multi-word values - converts to snake_case
259+
* Field.select(['In Progress', 'Closed Won'], { label: 'Status' })
260+
* // Results in: [{ label: 'In Progress', value: 'in_progress' }, { label: 'Closed Won', value: 'closed_won' }]
238261
*/
239262
select: (optionsOrConfig: SelectOption[] | string[] | FieldInput & { options: SelectOption[] | string[] }, config?: FieldInput) => {
263+
// Helper function to convert string to lowercase snake_case
264+
const toSnakeCase = (str: string): string => {
265+
return str
266+
.toLowerCase()
267+
.replace(/\s+/g, '_') // Replace spaces with underscores
268+
.replace(/[^a-z0-9_.]/g, ''); // Remove invalid characters
269+
};
270+
240271
// Support both old and new signatures:
241272
// Old: Field.select(['a', 'b'], { label: 'X' })
242273
// New: Field.select({ options: [{label: 'A', value: 'a'}], label: 'X' })
@@ -245,11 +276,19 @@ export const Field = {
245276

246277
if (Array.isArray(optionsOrConfig)) {
247278
// Old signature: array as first param
248-
options = optionsOrConfig.map(o => typeof o === 'string' ? { label: o, value: o } : o);
279+
options = optionsOrConfig.map(o =>
280+
typeof o === 'string'
281+
? { label: o, value: toSnakeCase(o) } // Auto-convert string to snake_case
282+
: { ...o, value: o.value.toLowerCase() } // Ensure value is lowercase
283+
);
249284
finalConfig = config || {};
250285
} else {
251286
// New signature: config object with options
252-
options = (optionsOrConfig.options || []).map(o => typeof o === 'string' ? { label: o, value: o } : o);
287+
options = (optionsOrConfig.options || []).map(o =>
288+
typeof o === 'string'
289+
? { label: o, value: toSnakeCase(o) } // Auto-convert string to snake_case
290+
: { ...o, value: o.value.toLowerCase() } // Ensure value is lowercase
291+
);
253292
// Remove options from config to avoid confusion
254293
const { options: _, ...restConfig } = optionsOrConfig;
255294
finalConfig = restConfig;

packages/spec/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
// ============================================================================
4343
// Export protocol domains as namespaces to prevent naming conflicts
4444
// and establish clear boundaries between different protocol layers.
45+
export * as Shared from './shared';
4546
export * as Data from './data';
4647
export * as Driver from './driver';
4748
export * as Permission from './permission';

packages/spec/src/permission/permission.zod.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod';
2+
import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
23

34
/**
45
* Entity (Object) Level Permissions
@@ -57,10 +58,24 @@ export const FieldPermissionSchema = z.object({
5758
* - Profile: The ONE primary functional definition of a user (e.g. Standard User).
5859
* - Permission Set: Add-on capabilities assigned to users (e.g. Export Reports).
5960
* - Role: (Defined in src/system/role.zod.ts) Defines data visibility hierarchy.
61+
*
62+
* **NAMING CONVENTION:**
63+
* Permission set names MUST be lowercase snake_case to prevent security issues.
64+
*
65+
* @example Good permission set names
66+
* - 'read_only'
67+
* - 'system_admin'
68+
* - 'standard_user'
69+
* - 'api_access'
70+
*
71+
* @example Bad permission set names (will be rejected)
72+
* - 'ReadOnly' (camelCase)
73+
* - 'SystemAdmin' (mixed case)
74+
* - 'Read Only' (spaces)
6075
*/
6176
export const PermissionSetSchema = z.object({
6277
/** Unique permission set name */
63-
name: z.string().describe('Permission set unique name'),
78+
name: SnakeCaseIdentifierSchema.describe('Permission set unique name (lowercase snake_case)'),
6479

6580
/** Display label */
6681
label: z.string().optional().describe('Display label'),

0 commit comments

Comments
 (0)