@unboundcx/sdk 4.5.0 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,7 @@ The official JavaScript SDK for Unbound's comprehensive communication and AI pla
14
14
  - 🤖 **AI**: Generative AI chat, text-to-speech, and speech-to-text
15
15
  - 💾 **Data**: Object management with queries and relationships
16
16
  - 🔄 **Workflows**: Programmable workflow execution
17
+ - 📄 **Documents**: Templates → PDF (`api.documents`); fax is a consumer, not the owner
17
18
  - 🔌 **Extensible**: Plugin system for transports and extensions
18
19
  - ⚡ **Performance**: Automatic transport optimization (NATS/Socket/HTTP)
19
20
 
@@ -174,6 +175,41 @@ await api.objects.updateById('contacts', 'contact-123', { name: 'Jane' });
174
175
  await api.objects.deleteById('contacts', 'contact-123');
175
176
  await api.objects.describe('contacts'); // Get schema
176
177
  await api.objects.list(); // List all object types
178
+
179
+ // Skip trigger execution on a write (imports / bulk tools)
180
+ await api.objects.updateById({
181
+ object: 'people',
182
+ id: '013…',
183
+ update: { leadScore: 200 },
184
+ skipTriggers: true,
185
+ });
186
+ ```
187
+
188
+ #### Triggers (`api.triggers`)
189
+
190
+ ```javascript
191
+ await api.triggers.listObjects();
192
+ await api.triggers.list({ objectName: 'people', status: 'enabled' });
193
+ await api.triggers.create({
194
+ name: 'Hot lead',
195
+ objectName: 'people',
196
+ actions: ['update'],
197
+ recordFilter: { type: { op: 'eq', value: 'lead' } },
198
+ changeFilters: [
199
+ {
200
+ field: 'leadScore',
201
+ previous: { op: 'lt', value: 20 },
202
+ updated: { op: 'gt', value: 100 },
203
+ },
204
+ ],
205
+ actionType: 'workflow',
206
+ actionConfig: { workflowVersionId: '052…' },
207
+ });
208
+ await api.triggers.get('173…');
209
+ await api.triggers.update('173…', { status: 'paused' });
210
+ await api.triggers.setStatus('173…', 'enabled');
211
+ await api.triggers.listFires('173…', { limit: 20 });
212
+ await api.triggers.delete('173…');
177
213
  ```
178
214
 
179
215
  #### Live Queries (`api.objects.liveQuery`)
@@ -408,6 +444,45 @@ const fileUrl = api.storage.getFileUrl(files[0].storageId);
408
444
  await api.storage.deleteFile(files[0].storageId);
409
445
  ```
410
446
 
447
+ #### Documents (`api.documents`)
448
+
449
+ Generic templates → PDF. Implementation: `services/documents.js`.
450
+ `this.documents = new DocumentsService(this)`.
451
+
452
+ ```javascript
453
+ const created = await api.documents.templates.create({
454
+ name: 'Fax cover',
455
+ engine: 'generative', // or 'overlay' + sourcePdfStorageId
456
+ uses: ['fax'], // omit / [] = every surface
457
+ });
458
+ await api.documents.templates.list({ status: 'published', use: 'fax' });
459
+ await api.documents.templates.update(created.id, { draftSchemaJson, draftLayoutJson });
460
+ await api.documents.templates.publish(created.id);
461
+
462
+ const doc = await api.documents.generate({
463
+ templateId: created.id,
464
+ data: { fromCompany: 'Acme', subject: 'Hello' },
465
+ });
466
+ // { id, storageId, pageCount, pageSize }
467
+
468
+ await api.fax.send({
469
+ faxMailboxId,
470
+ toNumber,
471
+ fromNumber,
472
+ storageId: doc.storageId,
473
+ coverStorageId, // optional; API concatenates cover then body
474
+ paperSize: doc.pageSize,
475
+ });
476
+
477
+ const pages = await api.documents.inspect({ storageId: doc.storageId });
478
+ ```
479
+
480
+ There is no `api.fax.generateFromTemplate`. Preview: `api.documents.preview({ templateId, data })`.
481
+
482
+ Published SDK versions older than this branch may not have `documents` or
483
+ `fax.send({ coverStorageId })`. The client falls back to `_fetch('/documents/…')`
484
+ and `_fetch('/fax/send')`.
485
+
411
486
  #### Workflows (`api.workflows`)
412
487
 
413
488
  ```javascript
package/index.js CHANGED
@@ -27,7 +27,9 @@ import { EngagementMetricsService } from './services/engagementMetrics.js';
27
27
  import { TaskRouterService } from './services/taskRouter.js';
28
28
  import { KnowledgeBaseService } from './services/knowledgeBase.js';
29
29
  import { FaxService } from './services/fax.js';
30
+ import { DocumentsService } from './services/documents.js';
30
31
  import { PermissionsService } from './services/permissions.js';
32
+ import { TriggersService } from './services/triggers.js';
31
33
 
32
34
  class UnboundSDK extends BaseSDK {
33
35
  constructor(options = {}) {
@@ -96,7 +98,9 @@ class UnboundSDK extends BaseSDK {
96
98
  this.taskRouter = new TaskRouterService(this);
97
99
  this.knowledgeBase = new KnowledgeBaseService(this);
98
100
  this.fax = new FaxService(this);
101
+ this.documents = new DocumentsService(this);
99
102
  this.permissions = new PermissionsService(this);
103
+ this.triggers = new TriggersService(this);
100
104
 
101
105
  // Add additional services that might be missing
102
106
  this._initializeAdditionalServices();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboundcx/sdk",
3
- "version": "4.5.0",
3
+ "version": "4.6.1",
4
4
  "description": "Official JavaScript SDK for the Unbound API - A comprehensive toolkit for integrating with Unbound's communication, AI, and data management services",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -61,6 +61,9 @@
61
61
  "./schemas/layouts": {
62
62
  "import": "./schemas/layouts/index.js"
63
63
  },
64
+ "./schemas/workflows": {
65
+ "import": "./schemas/workflows/index.js"
66
+ },
64
67
  "./schemas/*": {
65
68
  "import": "./schemas/*.js"
66
69
  }
@@ -0,0 +1,57 @@
1
+ import { z } from 'zod';
2
+
3
+ // Replaces the ad hoc mechanisms cataloged in design-schema-first.md §2
4
+ // (hardcoded `type === 'sendEmail'`/`type === 'sayIntent'` branches in
5
+ // `update/workflowItemSettings.js`, the separate hand-synced
6
+ // DATA_PRODUCER_TYPES registry, the redundant simulate+button dual
7
+ // mechanism, the dead `fieldType:'timeControl'` marker) with declared flags
8
+ // on the module itself. The whole `capabilities` object is OPTIONAL on
9
+ // ModuleSpec — absent means "legacy inferred behavior": every
10
+ // currently-shipping module keeps working exactly as today with zero flags
11
+ // set (design-incremental-risk.md §2's additive constraint). Execution
12
+ // (actually wiring these flags into the backend cascades) is explicitly
13
+ // out of scope for this phase — declaring the flag is authoring-time only.
14
+
15
+ const DynamicPortSyncSpec = z.object({
16
+ triggerField: z.string().min(1),
17
+ portId: z.string().min(1),
18
+ });
19
+
20
+ const SpawnsChildItemsSpec = z.object({
21
+ arrayField: z.string().min(1),
22
+ childType: z.string().min(1),
23
+ parentLinkField: z.string().min(1),
24
+ });
25
+
26
+ const DataProducerSpec = z.object({
27
+ fieldsResolverKey: z.string().min(1),
28
+ });
29
+
30
+ // Unifies the redundant dual mechanism found live in lookup.js/webHook.js/
31
+ // timeControl.js — a top-level `simulate:{enabled,endpoint}` block *and* a
32
+ // `fieldType:'button', button:{action:'simulate', settingsFields:[...]}`
33
+ // layout control, both present for the same "Run Test" feature in the same
34
+ // file — into one declared shape.
35
+ const SimulatableSpec = z.object({
36
+ endpoint: z.string().min(1),
37
+ settingsFields: z.array(z.string()).default([]),
38
+ });
39
+
40
+ // Formalizes the escape hatch scriptPage.js/timeControl.js already use ad
41
+ // hoc via string-match on `module.type` in EditPanel.svelte — the panel
42
+ // looks THIS up going forward, never string-matches `type` again.
43
+ const CustomEditorSpec = z.object({
44
+ componentKey: z.string().min(1),
45
+ });
46
+
47
+ export const CapabilitiesSpec = z.object({
48
+ deletable: z.boolean().default(true), // replaces isDeletable
49
+ hiddenFromPicker: z.boolean().default(false), // replaces isHiddenFromList
50
+ dynamicPorts: z.array(DynamicPortSyncSpec).nullable().default(null),
51
+ spawnsChildItems: SpawnsChildItemsSpec.nullable().default(null),
52
+ outputVariables: z.array(z.string()).default([]), // was outputVariableFields
53
+ excludedFromVariableExtraction: z.array(z.string()).default([]), // was excludeKeysFromVariableExtraction
54
+ dataProducer: DataProducerSpec.nullable().default(null), // kills DATA_PRODUCER_TYPES as a separate file
55
+ simulatable: SimulatableSpec.nullable().default(null),
56
+ customEditor: CustomEditorSpec.nullable().default(null),
57
+ }).passthrough();
@@ -0,0 +1,59 @@
1
+ import { z } from 'zod';
2
+
3
+ // Every fieldType literal observed across constants/workflows/*.js
4
+ // (verified via grep against all 29 files, not the idealized §2 list).
5
+ // Exported as a plain const rather than baked into a closed zod enum so
6
+ // validateModuleSpec stays permissive of any future fieldType a module
7
+ // author adds — lint.js is where "is this fieldType one we actually
8
+ // render" opinions live (e.g. rejecting 'switch').
9
+ export const KNOWN_FIELD_TYPES = [
10
+ 'input', 'select', 'selectDynamic', 'textArea', 'code', 'codeEditor',
11
+ 'checkbox', 'switch', 'button', 'routingPicker', 'timeControl', 'spacer',
12
+ ];
13
+
14
+ const ButtonSpec = z.object({
15
+ label: z.string().optional(),
16
+ icon: z.string().optional(),
17
+ style: z.string().optional(),
18
+ action: z.string().optional(),
19
+ modalTitle: z.string().optional(),
20
+ settingsFields: z.array(z.string()).optional(),
21
+ }).passthrough();
22
+
23
+ // select/selectDynamic config kept open (record) rather than a closed
24
+ // shape: static `{options:[{name,value}]}` and dynamic
25
+ // `{url,response,clearable,searchable,fetchOptions,...}` forms coexist
26
+ // per-field, plus module-specific extras (lookup.js's
27
+ // describeObject/dependsOn cascade, sendEmail.js's `{{settings.x}}`
28
+ // templated `url`, webHook.js's `display.relatedObject`/`relatedKey`
29
+ // divergence). Structural typing of this shape is deliberately deferred —
30
+ // same call the layouts schema package made for `componentConfig`.
31
+ const SelectConfigSpec = z.record(z.unknown());
32
+
33
+ // `edit[].field` is the one contract this whole package must never change
34
+ // the shape of: it is the exact dotted/bracketed JSON-path string the
35
+ // JSON_SET-style query builder in
36
+ // app1-api/.../customHandlers/update/workflowItemSettings.js consumes
37
+ // (`settings.<ns>.*` / `phone.*` / `messaging.*`, 'delete' array sentinel).
38
+ // This schema shape-checks it (non-empty string) and never parses/rewrites
39
+ // it.
40
+ export const EditEntrySpec = z.object({
41
+ field: z.string().min(1),
42
+ label: z.string().optional(),
43
+ helpText: z.string().optional(),
44
+ required: z.boolean().optional(),
45
+ placeholder: z.string().optional(),
46
+ fieldType: z.string().min(1),
47
+ fieldTypeSub: z.string().optional(),
48
+ value: z.string().optional(),
49
+ format: z.string().optional(),
50
+ select: SelectConfigSpec.optional(),
51
+ textArea: z.record(z.unknown()).optional(),
52
+ code: z.record(z.unknown()).optional(),
53
+ codeEditor: z.record(z.unknown()).optional(),
54
+ button: ButtonSpec.optional(),
55
+ routingPicker: z.record(z.unknown()).optional(),
56
+ variableSources: z.array(z.unknown()).optional(),
57
+ templateIdField: z.string().optional(),
58
+ emojiPicker: z.boolean().optional(),
59
+ }).passthrough();
@@ -0,0 +1,9 @@
1
+ export * from './primitives.js';
2
+ export * from './port.js';
3
+ export * from './capabilities.js';
4
+ export * from './editEntry.js';
5
+ export * from './layout.js';
6
+ export * from './settingsSchema.js';
7
+ export * from './moduleSpec.js';
8
+ export { validateModuleSpec } from './validate.js';
9
+ export { lintModuleSpec } from './lint.js';
@@ -0,0 +1,61 @@
1
+ import { z } from 'zod';
2
+ import { HeaderSpec } from '../layouts/primitives.js';
3
+ import { Conditional, InfoSpec, RepeatingSpec } from './primitives.js';
4
+ import { EditEntrySpec } from './editEntry.js';
5
+
6
+ // Column display metadata — the read-only mirror of an edit entry. `value`
7
+ // is the dotted/bracketed path read for display; it may intentionally
8
+ // diverge from `edit.field` (webHook.js's `relatedObject`/`relatedKey`
9
+ // pattern, maps.md module-defs §3 — a legitimate divergence, contrast with
10
+ // the buggy field/default mismatches lint.js catches).
11
+ const FieldDisplaySpec = z.object({
12
+ type: z.string().optional(),
13
+ label: z.string().optional(),
14
+ value: z.string().optional(),
15
+ format: z.string().optional(),
16
+ fieldType: z.string().optional(),
17
+ relatedObject: z.string().optional(),
18
+ relatedKey: z.string().optional(),
19
+ }).passthrough();
20
+
21
+ export const ModuleColumn = z.object({
22
+ type: z.string().optional(),
23
+ object: z.enum(['workflowItems', 'workflowItemSettings']).optional(),
24
+ conditional: Conditional.optional(),
25
+ display: FieldDisplaySpec.optional(),
26
+ // array form = composite/multi-field column (sendEmail.js's conditional
27
+ // Value columns switching on templateVariables[].type, scriptPage.js's
28
+ // Row 2 richText/text type-switch)
29
+ edit: z.union([EditEntrySpec, z.array(EditEntrySpec)]).optional(),
30
+ }).passthrough();
31
+
32
+ export const ModuleRow = z.object({
33
+ // row-level repeating exists (bot.js dataCollectionFields) alongside
34
+ // section-level repeating — both forms seen live, modeled independently
35
+ // rather than assuming one supersedes the other.
36
+ repeating: RepeatingSpec.optional(),
37
+ columns: z.array(ModuleColumn).default([]),
38
+ }).passthrough();
39
+
40
+ // `label` (plain section header text) and `tabLabel` (groups this section
41
+ // under a tab whose anchor is an earlier section's own id/label string
42
+ // match) are two distinct, coexisting mechanisms — maps.md module-defs §3's
43
+ // "tabbing model is inconsistent and undeclared as a concept" finding.
44
+ // Both modeled as-is; unifying them into one first-class `tabs` concept is
45
+ // out of scope for this schema pass (tracked in design-schema-first.md §3).
46
+ export const ModuleSection = z.object({
47
+ id: z.string().min(1),
48
+ label: z.string().optional(),
49
+ tabLabel: z.string().optional(),
50
+ header: HeaderSpec.partial().optional(),
51
+ conditional: Conditional.optional(),
52
+ info: InfoSpec.optional(),
53
+ repeating: RepeatingSpec.optional(),
54
+ showCollapse: z.boolean().optional(),
55
+ autoCollapse: z.boolean().optional(),
56
+ rows: z.array(ModuleRow).default([]),
57
+ }).passthrough();
58
+
59
+ export const ModuleLayoutSpec = z.object({
60
+ sections: z.array(ModuleSection).default([]),
61
+ }).passthrough();
@@ -0,0 +1,89 @@
1
+ // Extra, opinionated authoring rules beyond structural validity
2
+ // (validate.js). These are the checks design-schema-first.md §2 argues
3
+ // "would have caught every authoring bug found in the audit at commit
4
+ // time" — run them in CI against every module constants file, not just at
5
+ // runtime.
6
+
7
+ // Walks settings.layout.sections -> rows -> columns -> edit (single or
8
+ // array form) and returns a flat list of {entry, path} for every edit
9
+ // entry declared, regardless of nesting depth. Repeating sections/rows
10
+ // (their `field`/`edit.field` strings containing literal `{{index}}`) walk
11
+ // the same as any other row — the lint rules below operate on the string
12
+ // shape, not on resolved values.
13
+ function collectEditEntries(spec) {
14
+ const entries = [];
15
+ const sections = spec?.settings?.layout?.sections || [];
16
+ sections.forEach((section, sIdx) => {
17
+ (section.rows || []).forEach((row, rIdx) => {
18
+ (row.columns || []).forEach((column, cIdx) => {
19
+ const edit = column.edit;
20
+ if (!edit) return;
21
+ const isArray = Array.isArray(edit);
22
+ const list = isArray ? edit : [edit];
23
+ list.forEach((entry, eIdx) => {
24
+ const editPath = isArray ? `edit[${eIdx}]` : 'edit';
25
+ entries.push({
26
+ entry,
27
+ path: `settings.layout.sections[${sIdx}].rows[${rIdx}].columns[${cIdx}].${editPath}`,
28
+ });
29
+ });
30
+ });
31
+ });
32
+ });
33
+ return entries;
34
+ }
35
+
36
+ export function lintModuleSpec(spec) {
37
+ const errors = [];
38
+ const entries = collectEditEntries(spec);
39
+
40
+ // Rule: category must be provided. The registry cross-check (does this
41
+ // category match workflows.js's grouping) happens api-side — this rule
42
+ // only enforces that the module declares one at all.
43
+ if (!spec?.category) {
44
+ errors.push({
45
+ rule: 'category-required',
46
+ path: 'category',
47
+ message: 'category must be provided (registry cross-check happens api-side)',
48
+ });
49
+ }
50
+
51
+ // Rule: boolean widget must be 'checkbox'. 'switch' has no renderer at
52
+ // all today — peopleCompanyLink.js's "Create If Not Found" field is
53
+ // configured but silently unsettable through the UI (maps.md
54
+ // module-defs §4.11, edit-panel §3/§4.3).
55
+ entries.forEach(({ entry, path }) => {
56
+ if (entry.fieldType === 'switch') {
57
+ errors.push({
58
+ rule: 'boolean-widget-checkbox',
59
+ path: `${path}.fieldType`,
60
+ message: `fieldType 'switch' has no renderer — use 'checkbox' (field: ${entry.field})`,
61
+ });
62
+ }
63
+ });
64
+
65
+ // Rule: every edit[].field's settings.<ns> root must match the module's
66
+ // declared settingsSchema.namespace. Only checked once a module has
67
+ // opted into settingsSchema (additive: unmigrated modules with no
68
+ // settingsSchema skip this rule entirely — they simply aren't covered
69
+ // yet, not "passing"). Fields addressing 'description' (workflowItems)
70
+ // or the phone.*/messaging.* dual-addressing channel are exempt — they
71
+ // never sit under 'settings.' at all.
72
+ const namespace = spec?.settingsSchema?.namespace;
73
+ if (namespace) {
74
+ entries.forEach(({ entry, path }) => {
75
+ const { field } = entry;
76
+ if (typeof field !== 'string' || !field.startsWith('settings.')) return;
77
+ const ns = field.slice('settings.'.length).split(/[.[]/)[0];
78
+ if (ns !== namespace) {
79
+ errors.push({
80
+ rule: 'settings-namespace-match',
81
+ path: `${path}.field`,
82
+ message: `field '${field}' writes to settings.${ns}, but settingsSchema.namespace is '${namespace}' — this field silently no-ops (aBRouting bug class, maps.md module-defs §4.3)`,
83
+ });
84
+ }
85
+ });
86
+ }
87
+
88
+ return { valid: errors.length === 0, errors };
89
+ }
@@ -0,0 +1,87 @@
1
+ import { z } from 'zod';
2
+ import { PortSpec } from './port.js';
3
+ import { CapabilitiesSpec } from './capabilities.js';
4
+ import { SettingsSchemaSpec } from './settingsSchema.js';
5
+ import { ModuleLayoutSpec } from './layout.js';
6
+
7
+ // Top-level shape. DEVIATION from design-schema-first.md §2's idealized
8
+ // sketch, which shows `layout:{sections}` and `settingsSchema` as flat
9
+ // siblings of `capabilities` at the module's top level: verified against
10
+ // all 29 real files in
11
+ // app1-api/src/services/objects/constants/workflows/*.js (wait.js, say.js,
12
+ // sendEmail.js, aBRouting.js, …), the layout tree and every module's own
13
+ // default-state object live NESTED inside one `settings` key
14
+ // (`module.settings.layout.sections`, `module.settings.<namespace>`) — not
15
+ // as top-level siblings. Modeling the idealized flat shape here would fail
16
+ // every existing module file, violating the "untouched modules must load
17
+ // exactly as before" constraint. `capabilities`/`settingsSchema`/
18
+ // `moduleSchemaVersion` are genuinely new, additive top-level keys — no
19
+ // module ships them today; `settings.layout`/`settings.<ns>` are the real,
20
+ // unchanged existing tree these new keys sit alongside.
21
+ export const ModuleSpec = z.object({
22
+ // Identity
23
+ type: z.string().min(1),
24
+ // Cross-checked against the workflows.js registry api-side
25
+ // (design-schema-first.md §2) — kept optional/permissive here so this
26
+ // schema alone never rejects a real file for a missing category;
27
+ // lintModuleSpec is where "category must be provided" is enforced.
28
+ category: z.enum(['ai', 'engagement', 'data', 'actions', 'routing']).optional(),
29
+ moduleSchemaVersion: z.number().int().positive().optional(),
30
+
31
+ // Presentation
32
+ label: z.string().min(1),
33
+ description: z.string().optional(),
34
+ icon: z.string().optional(),
35
+ iconBgColor: z.string().optional(),
36
+ iconTextColor: z.string().optional(),
37
+ labelBgColor: z.string().optional(),
38
+ labelTextColor: z.string().optional(),
39
+ descriptionBgColor: z.string().optional(),
40
+ descriptionTextColor: z.string().optional(),
41
+
42
+ // Canvas contract
43
+ position: z.object({ x: z.number(), y: z.number() }).optional(),
44
+ ports: z.array(PortSpec).default([]),
45
+ editWidth: z.string().optional(),
46
+
47
+ // start.js / sayIntent.js's sayIntentOption sub-type only set these
48
+ // explicitly — every other module omits both, relying on the implicit
49
+ // default (maps.md module-defs §3). `capabilities.deletable`/
50
+ // `hiddenFromPicker` are the new declared equivalents; these legacy keys
51
+ // are kept typed (not just passthrough) since they still drive real
52
+ // behavior today and a migrated module may set both during the soak
53
+ // period.
54
+ isDeletable: z.boolean().optional(),
55
+ isHiddenFromList: z.boolean().optional(),
56
+
57
+ // New, additive-only (see DEVIATION note above)
58
+ capabilities: CapabilitiesSpec.optional(),
59
+ settingsSchema: SettingsSchemaSpec.optional(),
60
+
61
+ // Legacy pre-capabilities metadata, still real and still read by the
62
+ // backend today (maps.md module-defs §2): `outputVariableFields`
63
+ // (documentProcessing.js, summary.js), `excludeKeysFromVariableExtraction`
64
+ // (selectResult.js), `subModules` (sayIntent.js), `simulate`
65
+ // (lookup.js/timeControl.js/webHook.js). `capabilities.outputVariables`/
66
+ // `excludedFromVariableExtraction`/`dataProducer`/`simulatable` are meant
67
+ // to supersede these one module at a time — kept typed here (not left to
68
+ // bare passthrough) so both shapes can be inspected side by side during
69
+ // migration.
70
+ outputVariableFields: z.array(z.string()).optional(),
71
+ excludeKeysFromVariableExtraction: z.array(z.string()).optional(),
72
+ subModules: z.array(z.unknown()).optional(),
73
+ simulate: z.object({
74
+ enabled: z.boolean().optional(),
75
+ endpoint: z.string().optional(),
76
+ }).passthrough().optional(),
77
+
78
+ // The real, load-bearing tree — see DEVIATION note above. `settings`
79
+ // itself is passthrough because every module's own namespace key
80
+ // (`wait: {...}`, `email: {...}`, `abRoute: {...}`, …) sits here as a
81
+ // sibling of `layout`, and there is deliberately no closed list of valid
82
+ // namespace keys at this layer — `settingsSchema.namespace` is the
83
+ // declared source of truth for the ones that opt in.
84
+ settings: z.object({
85
+ layout: ModuleLayoutSpec.optional(),
86
+ }).passthrough().optional(),
87
+ }).passthrough();
@@ -0,0 +1,22 @@
1
+ import { z } from 'zod';
2
+
3
+ export const PortDirection = z.enum(['in', 'out']);
4
+
5
+ // Mirrors the workflowItemPorts table (maps.md canvas §2) and the `ports: []`
6
+ // literals in every constants/workflows/*.js file. Real files never declare
7
+ // an explicit port `id` — ports are addressed by direction+label today
8
+ // (label-string routing into `getNextModule.js`, flagged as a separate,
9
+ // out-of-scope correctness workstream by design-schema-first.md §5.2/
10
+ // design-incremental-risk.md §5) — kept optional here, not required, so
11
+ // every existing module's ports[] validates unchanged.
12
+ export const PortSpec = z.object({
13
+ id: z.string().optional(),
14
+ direction: PortDirection,
15
+ label: z.string().optional(),
16
+ isHidden: z.boolean().default(false),
17
+ isMultiple: z.boolean().default(false),
18
+ isLocked: z.boolean().default(false),
19
+ isConnectionDeletable: z.boolean().default(true),
20
+ bgColor: z.string().optional(),
21
+ textColor: z.string().optional(),
22
+ }).passthrough();
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+
3
+ // Conditional visibility expression for workflow module layout sections/
4
+ // columns. Normalizes the two shapes found across
5
+ // app1-api/src/services/objects/constants/workflows/*.js (maps.md
6
+ // module-defs §3/§4.12): the implicit-equals shorthand `{field, value}`
7
+ // used by ~28 of 29 modules, and the one explicit-operator occurrence
8
+ // (sendEmail.js:554-557, `operator: 'not_empty'`, no `value` key at all).
9
+ // `operator` defaults to 'equals' via preprocessing so every existing
10
+ // layout's `{field, value}` shape parses unchanged — this is
11
+ // authoring-time normalization only, never a change to how the client
12
+ // evaluates the condition.
13
+ export const ConditionOperator = z.enum(['equals', 'not_empty']);
14
+
15
+ export const Conditional = z.preprocess(
16
+ (v) => (v && typeof v === 'object' && !('operator' in v) ? { ...v, operator: 'equals' } : v),
17
+ z.object({
18
+ field: z.string().min(1),
19
+ operator: ConditionOperator.default('equals'),
20
+ value: z.union([z.string(), z.number(), z.boolean(), z.null()]).optional(),
21
+ }).refine(
22
+ (v) => v.operator === 'not_empty' || v.value !== undefined,
23
+ { message: 'value is required unless operator is not_empty', path: ['value'] },
24
+ ),
25
+ );
26
+
27
+ // `info: {type,message}` banner block seen on ~half the modules
28
+ // (wait.js has none; sendEmail.js/selectResult.js/lookup.js do).
29
+ export const InfoSpec = z.object({
30
+ type: z.string().min(1),
31
+ message: z.string().min(1),
32
+ }).passthrough();
33
+
34
+ // Repeating section/row config — real occurrences: section-level
35
+ // (scriptPage.js x4, sendEmail.js templateVariables, lookup.js/webHook.js
36
+ // conditions, setVariable.js, sayIntent.js) and row-level (bot.js
37
+ // dataCollectionFields). `field` is the array-typed settings path the
38
+ // "+ Add" button pushes a new element into.
39
+ export const RepeatingSpec = z.object({
40
+ enabled: z.boolean().default(true),
41
+ field: z.string().min(1),
42
+ object: z.enum(['workflowItems', 'workflowItemSettings']).optional(),
43
+ newLabel: z.string().optional(),
44
+ }).passthrough();
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+
3
+ // Declares WHERE a module's behavior fields live instead of leaving it to
4
+ // convention (design-schema-first.md §2). `namespace` must match the first
5
+ // path segment after `settings.` on every `settings.<ns>.*` edit field this
6
+ // module declares — see lint.js's settings-namespace-match rule, which is
7
+ // exactly what would mechanically catch aBRouting.js's live field/default
8
+ // mismatch (`edit.field: 'settings.points.unit'` writes a key nothing
9
+ // reads; the module's real default state lives under `settings.abRoute.*`,
10
+ // which is never editable through the UI at all — maps.md module-defs
11
+ // §4.3) and update.js's copy-pasted-and-never-renamed defaults (§4.2) at
12
+ // commit time, not after the bug ships.
13
+ //
14
+ // Optional on ModuleSpec as a whole — a module with no settingsSchema is
15
+ // simply not yet opted into this check (legacy/unmigrated), per the
16
+ // additive constraint in design-incremental-risk.md §2. `phone.*`/
17
+ // `messaging.*` dual-channel fields (say/sayGather/sayIntent/bot/
18
+ // selectResult, maps.md module-defs §2) are exempt from the namespace
19
+ // check entirely — that's a documented, separate addressing channel, not a
20
+ // `settings.<ns>` deviation, and its collapse into `settings` is explicitly
21
+ // deferred (design-schema-first.md §1).
22
+ //
23
+ // `defaults` mirrors the module's own default-state object (e.g.
24
+ // wait.js's bottom-level `wait: {unit,value}`, unwrapped — NOT
25
+ // `{wait: {unit,value}}` again) purely for documentation/tooling; it is not
26
+ // re-injected into `settings.<ns>` at runtime by anything in this package.
27
+ export const SettingsSchemaSpec = z.object({
28
+ namespace: z.string().min(1),
29
+ defaults: z.record(z.unknown()).default({}),
30
+ }).passthrough();
@@ -0,0 +1,18 @@
1
+ import { ModuleSpec } from './moduleSpec.js';
2
+
3
+ // Structural validity only — "is this a well-formed ModuleSpec". Opinions
4
+ // about authoring quality (missing category, banned fieldTypes, field/
5
+ // namespace mismatches) live in lintModuleSpec, which every module also
6
+ // passing validateModuleSpec should additionally pass before being
7
+ // considered fully migrated.
8
+ export function validateModuleSpec(spec) {
9
+ const result = ModuleSpec.safeParse(spec);
10
+ if (result.success) {
11
+ return { valid: true, errors: [], data: result.data };
12
+ }
13
+ return {
14
+ valid: false,
15
+ errors: result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
16
+ data: null,
17
+ };
18
+ }