@lowdefy/build 5.6.0 → 6.1.0

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.
Files changed (102) hide show
  1. package/dist/build/buildAgents.js +61 -16
  2. package/dist/build/buildApi/buildApi.js +4 -1
  3. package/dist/build/buildApi/buildEndpoint.js +8 -2
  4. package/dist/build/buildApi/buildRoutine/countStepTypes.js +1 -1
  5. package/dist/build/buildApi/buildRoutine/setStepId.js +2 -0
  6. package/dist/build/buildApi/buildRoutine/validateStep.js +61 -0
  7. package/dist/build/buildApi/resolveEndpointSchedules.js +40 -0
  8. package/dist/build/buildApi/validateEndpoint.js +96 -1
  9. package/dist/build/buildApp.js +13 -20
  10. package/dist/build/buildAppMeta.js +138 -0
  11. package/dist/build/buildAuth/buildAgentAuth.js +14 -1
  12. package/dist/build/buildAuth/buildAuth.js +8 -3
  13. package/dist/build/buildAuth/buildAuthPlugins.js +6 -0
  14. package/dist/build/buildAuth/buildAuthStrategies.js +117 -0
  15. package/dist/build/buildAuth/buildWebsocketAuth.js +55 -0
  16. package/dist/build/buildAuth/getProtectedWebsockets.js +29 -0
  17. package/dist/build/buildAuth/getWebsocketRoles.js +39 -0
  18. package/dist/build/buildAuth/validateAuthConfig.js +15 -3
  19. package/dist/build/buildEnvironments.js +261 -0
  20. package/dist/build/buildImports/buildImportsDev.js +15 -0
  21. package/dist/build/buildImports/buildImportsProd.js +4 -1
  22. package/dist/build/buildJs/generateClientJsModule.js +27 -0
  23. package/dist/build/buildJs/writeJs.js +3 -5
  24. package/dist/build/buildLogger.js +22 -6
  25. package/dist/build/buildMcp.js +73 -0
  26. package/dist/build/buildModuleDefs.js +90 -48
  27. package/dist/build/buildModules.js +61 -14
  28. package/dist/build/buildNotifications.js +98 -0
  29. package/dist/build/buildPages/buildBlock/buildBlock.js +3 -1
  30. package/dist/build/buildPages/buildBlock/buildDynamicBlock.js +131 -0
  31. package/dist/build/buildPages/buildBlock/buildEvents.js +186 -21
  32. package/dist/build/buildPages/buildBlock/buildRequests.js +9 -0
  33. package/dist/build/buildPages/buildBlock/normalizeClassAndStyles.js +2 -11
  34. package/dist/build/buildPages/buildBlock/setBlockId.js +5 -2
  35. package/dist/build/buildPages/buildBlock/validateBlock.js +7 -0
  36. package/dist/build/buildPages/buildPage.js +27 -3
  37. package/dist/build/buildPages/buildSubscriptions.js +145 -0
  38. package/dist/build/buildPages/validateDynamicBlockRefs.js +30 -0
  39. package/dist/build/buildPages/validateStateReferences.js +4 -2
  40. package/dist/build/buildPages/validateWebsocketRefs.js +30 -0
  41. package/dist/build/buildRefs/buildRefs.js +21 -12
  42. package/dist/build/buildRefs/cloneWithMarkers.js +57 -0
  43. package/dist/build/buildRefs/deferredRegions.js +80 -0
  44. package/dist/build/buildRefs/deferredRegistry.js +292 -0
  45. package/dist/build/buildRefs/getModuleRefContent.js +21 -6
  46. package/dist/build/buildRefs/getUserJavascriptFunction.js +1 -1
  47. package/dist/build/buildRefs/parseRefContent.js +24 -0
  48. package/dist/build/buildRefs/{evaluateStaticOperators.js → precomputeRuntimeOperators.js} +7 -6
  49. package/dist/build/buildRefs/walker.js +471 -207
  50. package/dist/build/buildTypes.js +22 -1
  51. package/dist/build/buildWebsockets.js +99 -0
  52. package/dist/build/collectPageContent.js +7 -1
  53. package/dist/build/copyAgentFileSystems.js +2 -2
  54. package/dist/build/full/buildPages.js +16 -0
  55. package/dist/build/full/updateServerPackageJson.js +21 -0
  56. package/dist/build/jit/buildPageJit.js +46 -6
  57. package/dist/build/jit/collectSkeletonSourceFiles.js +14 -0
  58. package/dist/build/jit/pageContentKeys.js +1 -0
  59. package/dist/build/jit/shallowBuild.js +84 -3
  60. package/dist/build/jit/writePageJit.js +11 -8
  61. package/dist/build/registerModules.js +125 -24
  62. package/dist/build/validateCallAgentSteps.js +58 -0
  63. package/dist/build/validateConfig.js +6 -1
  64. package/dist/build/validateEnvironmentGuards.js +69 -0
  65. package/dist/build/validateRenderNotificationSteps.js +50 -0
  66. package/dist/build/writeApi.js +98 -1
  67. package/dist/build/writeMcp.js +21 -0
  68. package/dist/build/writeNotifications.js +30 -0
  69. package/dist/build/writePluginImports/importPluginModule.js +39 -0
  70. package/dist/build/writePluginImports/writeActionSchemaMap.js +6 -7
  71. package/dist/build/writePluginImports/writeAuthImports.js +4 -0
  72. package/dist/build/writePluginImports/writeAvailableTypes.js +99 -0
  73. package/dist/build/writePluginImports/writeBlockSchemaMap.js +10 -9
  74. package/dist/build/writePluginImports/writeConnectionSchemaMap.js +101 -0
  75. package/dist/build/writePluginImports/writeGlobalsCss.js +20 -10
  76. package/dist/build/writePluginImports/writeNotificationImports.js +31 -0
  77. package/dist/build/writePluginImports/writeOperatorSchemaMap.js +6 -7
  78. package/dist/build/writePluginImports/writePluginImports.js +18 -4
  79. package/dist/build/writePluginImports/writeWebsocketImports.js +22 -0
  80. package/dist/build/writeWebsockets.js +30 -0
  81. package/dist/createContext.js +6 -1
  82. package/dist/defaultPackages.js +7 -1
  83. package/dist/defaultTypesMap.js +688 -388
  84. package/dist/dynamic/buildDynamicBlocks.js +120 -0
  85. package/dist/index.js +81 -10
  86. package/dist/indexDev.js +4 -0
  87. package/dist/lowdefySchema.js +969 -6
  88. package/dist/scripts/generateDefaultTypes.js +5 -2
  89. package/dist/test-utils/expectTerminates.js +33 -0
  90. package/dist/test-utils/runBuild.js +15 -1
  91. package/dist/test-utils/runBuildForSnapshots.js +37 -4
  92. package/dist/test-utils/testContext.js +8 -2
  93. package/dist/utils/countOperators.js +5 -17
  94. package/dist/utils/createPluginTypesMap.js +21 -0
  95. package/dist/utils/getEnvironmentNames.js +26 -0
  96. package/dist/utils/logCollectedErrors.js +15 -9
  97. package/dist/utils/serializeBuildException.js +29 -0
  98. package/dist/utils/validateCronExpression.js +131 -0
  99. package/dist/utils/writeBuildArtifact.js +5 -2
  100. package/package.json +67 -60
  101. package/dist/build/writePluginImports/writeServerExternalPackages.js +0 -121
  102. /package/dist/build/{resolveModuleOperators.js → buildRefs/scopeMenuItemIds.js} +0 -0
@@ -25,17 +25,33 @@ const sentryDefaults = {
25
25
  '_id'
26
26
  ]
27
27
  };
28
+ // Runs after validateConfig, so config.environment and config.environments are resolved.
28
29
  function buildLogger({ components }) {
29
30
  if (type.isNone(components.logger)) {
30
31
  components.logger = {};
31
32
  }
32
- // Only apply defaults if sentry is explicitly configured
33
- if (!type.isNone(components.logger.sentry)) {
34
- components.logger.sentry = {
35
- ...sentryDefaults,
36
- ...components.logger.sentry
37
- };
33
+ const current = components.config.environment;
34
+ // Sentry is enabled by SENTRY_DSN, not by the config being present, so a current environment
35
+ // creates the Sentry config to carry its name. Without either, the runtime defaults apply.
36
+ if (type.isNone(components.logger.sentry) && type.isNone(current)) {
37
+ return components;
38
38
  }
39
+ const sentry = {
40
+ ...sentryDefaults,
41
+ ...components.logger.sentry ?? {}
42
+ };
43
+ if (!type.isNone(current)) {
44
+ // Sentry reports under the environment name unless the app names one itself, and is off on
45
+ // both sides when the environment switches it off.
46
+ if (type.isNone(sentry.environment)) {
47
+ sentry.environment = current;
48
+ }
49
+ if (components.config.environments?.[current]?.sentry?.enabled === false) {
50
+ sentry.client = false;
51
+ sentry.server = false;
52
+ }
53
+ }
54
+ components.logger.sentry = sentry;
39
55
  return components;
40
56
  }
41
57
  export default buildLogger;
@@ -0,0 +1,73 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ /* eslint-disable no-param-reassign */ import { type } from '@lowdefy/helpers';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ // Validates the mcp block and writes its defaults. Endpoints are matched via
18
+ // the same dual id/endpointId match buildApi uses. Referenced endpoints must
19
+ // carry the metadata an MCP client needs: a description and a payloadSchema
20
+ // (the tool inputSchema).
21
+ function buildMcp({ components }) {
22
+ if (type.isNone(components.mcp)) {
23
+ components.mcp = {};
24
+ }
25
+ const mcp = components.mcp;
26
+ const configKey = mcp['~k'] ?? components['~k'];
27
+ // Agent tools were removed from mcp; schema validation only warns on
28
+ // unknown properties, so a leftover "agents" key would otherwise build
29
+ // silently instead of surfacing the removal.
30
+ if (!type.isNone(mcp.agents)) {
31
+ throw new ConfigError('MCP agent tools are not supported. Remove "mcp.agents" from your config.', {
32
+ configKey
33
+ });
34
+ }
35
+ mcp.name = mcp.name ?? 'lowdefy';
36
+ mcp.version = mcp.version ?? '1.0.0';
37
+ mcp.endpoints = mcp.endpoints ?? [];
38
+ mcp.configured = mcp.endpoints.length > 0;
39
+ const seen = new Set();
40
+ mcp.endpoints.forEach((endpointId)=>{
41
+ if (seen.has(endpointId)) {
42
+ throw new ConfigError(`Duplicate MCP tool "${endpointId}".`, {
43
+ configKey
44
+ });
45
+ }
46
+ seen.add(endpointId);
47
+ const endpoint = (components.api ?? []).find((e)=>e.id === endpointId || e.endpointId === endpointId);
48
+ if (type.isNone(endpoint)) {
49
+ throw new ConfigError(`MCP endpoint "${endpointId}" does not reference a defined api endpoint.`, {
50
+ configKey
51
+ });
52
+ }
53
+ // MCP is an external transport - InternalApi endpoints are not addressable
54
+ // from outside the server, matching the HTTP endpoint route.
55
+ if (endpoint.type === 'InternalApi') {
56
+ throw new ConfigError(`MCP endpoint "${endpointId}" is an InternalApi endpoint. Only "Api" endpoints can be exposed as MCP tools.`, {
57
+ configKey
58
+ });
59
+ }
60
+ if (type.isNone(endpoint.description)) {
61
+ throw new ConfigError(`Endpoint "${endpointId}" is exposed as an MCP tool but does not have a "description".`, {
62
+ configKey: endpoint['~k'] ?? configKey
63
+ });
64
+ }
65
+ if (type.isNone(endpoint.payloadSchema)) {
66
+ throw new ConfigError(`Endpoint "${endpointId}" is exposed as an MCP tool but does not have a "payloadSchema".`, {
67
+ configKey: endpoint['~k'] ?? configKey
68
+ });
69
+ }
70
+ });
71
+ return components;
72
+ }
73
+ export default buildMcp;
@@ -14,13 +14,13 @@
14
14
  limitations under the License.
15
15
  */ import operators from '@lowdefy/operators-js/operators/build';
16
16
  import { resolve, WalkContext } from './buildRefs/walker.js';
17
+ import { assertNoPlaceholderLeaks } from './buildRefs/deferredRegistry.js';
17
18
  import getRefContent from './buildRefs/getRefContent.js';
18
19
  import makeRefDefinition from './buildRefs/makeRefDefinition.js';
19
- import evaluateStaticOperators from './buildRefs/evaluateStaticOperators.js';
20
20
  import collectDynamicIdentifiers from './collectDynamicIdentifiers.js';
21
21
  import validateOperatorsDynamic from './validateOperatorsDynamic.js';
22
22
  import fetchModules from './fetchModules.js';
23
- import { resolveLocalManifest, resolveFullManifest, validateRequiredVars } from './registerModules.js';
23
+ import { resolveLocalManifest, recordifyExportables, resolveFullManifest, validateRequiredVars } from './registerModules.js';
24
24
  import resolveModuleDependencies from './resolveModuleDependencies.js';
25
25
  validateOperatorsDynamic({
26
26
  operators
@@ -50,64 +50,86 @@ async function parseLowdefyYaml({ context }) {
50
50
  ] : []),
51
51
  operators,
52
52
  env: process.env,
53
+ lowdefyApp: context.appMeta,
53
54
  dynamicIdentifiers,
54
55
  shouldStop: (path)=>{
55
56
  // Defer entry vars and connections: they may contain cross-module
56
- // refs that require modules to be registered first.
57
- if (/^modules\.\d+\.vars$/.test(path)) return 'preserve';
58
- if (/^modules\.\d+\.connections$/.test(path)) return 'preserve';
57
+ // refs that require modules to be registered first. Non-modules keys
58
+ // stay raw for the app buildRefs pass.
59
+ if (/^modules\.\d+\.vars$/.test(path)) return 'skip';
60
+ if (/^modules\.\d+\.connections$/.test(path)) return 'skip';
59
61
  if (path.startsWith('modules')) return false;
60
- return 'preserve';
62
+ return 'skip';
61
63
  }
62
64
  });
63
- let config = await resolve(content, ctx);
64
- config = evaluateStaticOperators({
65
- context,
66
- input: config,
67
- refDef
68
- });
65
+ const config = await resolve(content, ctx);
69
66
  return config ?? {};
70
67
  }
71
- async function resolveEntryConfig({ entry, context }) {
72
- const moduleEntry = context.modules[entry.id];
68
+ function makeAppLevelCtx({ context, deferModuleRefs = false, entryId, entrySection }) {
73
69
  const lowdefyYamlRefDef = context.lowdefyYamlRefDef;
74
- function makeAppLevelCtx() {
75
- return new WalkContext({
76
- buildContext: context,
77
- refId: lowdefyYamlRefDef.id,
78
- sourceRefId: null,
79
- vars: {},
80
- path: '',
81
- currentFile: lowdefyYamlRefDef.path,
82
- refChain: new Set(lowdefyYamlRefDef.path ? [
83
- lowdefyYamlRefDef.path
84
- ] : []),
85
- operators,
86
- env: process.env,
87
- dynamicIdentifiers
88
- });
89
- }
90
- const refDef = lowdefyYamlRefDef;
91
- let resolvedVars = await resolve(moduleEntry.consumerVars, makeAppLevelCtx());
92
- resolvedVars = evaluateStaticOperators({
93
- context,
94
- input: resolvedVars,
95
- refDef
70
+ return new WalkContext({
71
+ buildContext: context,
72
+ refId: lowdefyYamlRefDef.id,
73
+ sourceRefId: null,
74
+ vars: {},
75
+ path: '',
76
+ currentFile: lowdefyYamlRefDef.path,
77
+ refChain: new Set(lowdefyYamlRefDef.path ? [
78
+ lowdefyYamlRefDef.path
79
+ ] : []),
80
+ operators,
81
+ env: process.env,
82
+ lowdefyApp: context.appMeta,
83
+ dynamicIdentifiers,
84
+ deferModuleRefs,
85
+ entryId,
86
+ entrySection
96
87
  });
97
- moduleEntry.consumerVars = resolvedVars ?? {};
98
- let resolvedConnections = await resolve(moduleEntry.connections, makeAppLevelCtx());
99
- resolvedConnections = evaluateStaticOperators({
88
+ }
89
+ // Stage 1 — prepare: resolve entry vars/connections in the enclosing (app)
90
+ // scope; cross-module refs become entryRef records with the prepared refDef as
91
+ // body (their dynamic parts already resolved), leaving placeholders in the
92
+ // blobs. Order-free: nothing is consumed here.
93
+ async function prepareEntryConfig({ moduleEntry, context }) {
94
+ const varsResult = await resolve(moduleEntry.consumerVars, makeAppLevelCtx({
100
95
  context,
101
- input: resolvedConnections,
102
- refDef
103
- });
104
- moduleEntry.connections = resolvedConnections ?? {};
105
- validateRequiredVars(moduleEntry.varDefs, moduleEntry.consumerVars, entry.id, entry.source);
96
+ deferModuleRefs: true,
97
+ entryId: moduleEntry.id,
98
+ entrySection: 'consumerVars'
99
+ }));
100
+ moduleEntry.consumerVars = varsResult ?? {};
101
+ const connectionsResult = await resolve(moduleEntry.connections, makeAppLevelCtx({
102
+ context,
103
+ deferModuleRefs: true,
104
+ entryId: moduleEntry.id,
105
+ entrySection: 'connections'
106
+ }));
107
+ moduleEntry.connections = connectionsResult ?? {};
108
+ }
109
+ // Final sweep: re-walk each entry's blobs with deferral off. Placeholders
110
+ // dispatch into resolveDeferred (value-granular demand, wait-graph cycle
111
+ // detection — order immaterial, memo skips already-demanded records), and the
112
+ // _module.connectionId object form (deferred during prepare) resolves against
113
+ // now-prepared targets. In-place walk mutation is the write-back, so resolved
114
+ // values land in consumerVars/connections with consumption provenance.
115
+ async function sweepEntryConfig({ moduleEntry, context }) {
116
+ const varsResult = await resolve(moduleEntry.consumerVars, makeAppLevelCtx({
117
+ context
118
+ }));
119
+ moduleEntry.consumerVars = varsResult ?? {};
120
+ const connectionsResult = await resolve(moduleEntry.connections, makeAppLevelCtx({
121
+ context
122
+ }));
123
+ moduleEntry.connections = connectionsResult ?? {};
124
+ validateRequiredVars(moduleEntry.varDefs, moduleEntry.consumerVars, moduleEntry.id, moduleEntry.source);
106
125
  }
107
126
  async function buildModuleDefs({ context }) {
108
127
  const lowdefyConfig = await parseLowdefyYaml({
109
128
  context
110
129
  });
130
+ // Stash for index.js to compute appMeta before buildRefs, so the _build.app
131
+ // operator can resolve app metadata during ref resolution.
132
+ context.lowdefyConfig = lowdefyConfig;
111
133
  context.plugins = lowdefyConfig.plugins ?? [];
112
134
  const moduleEntries = lowdefyConfig.modules ?? [];
113
135
  if (moduleEntries.length === 0) {
@@ -129,11 +151,28 @@ async function buildModuleDefs({ context }) {
129
151
  resolveModuleDependencies({
130
152
  context
131
153
  });
132
- // Step 2.5: Resolve deferred entry vars and connections at app level,
133
- // then validate required vars against the resolved values.
154
+ // Step 2.5a — prepare all entries. No cross-entry work; order-free.
134
155
  for (const entry of moduleEntries){
135
- await resolveEntryConfig({
136
- entry,
156
+ await prepareEntryConfig({
157
+ moduleEntry: context.modules[entry.id],
158
+ context
159
+ });
160
+ }
161
+ // Phase C.5 — record-ify exportables (components, menu links) for every
162
+ // entry BEFORE anything can consume them: record creation must precede all
163
+ // consumption, or cross-entry refs would race manifest resolve order.
164
+ for (const entry of moduleEntries){
165
+ await recordifyExportables({
166
+ entryId: entry.id,
167
+ context
168
+ });
169
+ }
170
+ // Step 2.5b — final sweep. Per-record demand through the wait-graph makes
171
+ // the order immaterial; required-var validation runs per entry after its
172
+ // blobs are concrete.
173
+ for (const entry of moduleEntries){
174
+ await sweepEntryConfig({
175
+ moduleEntry: context.modules[entry.id],
137
176
  context
138
177
  });
139
178
  }
@@ -144,5 +183,8 @@ async function buildModuleDefs({ context }) {
144
183
  context
145
184
  });
146
185
  }
186
+ // Post-sweep invariant: no deferred placeholder survives outside the
187
+ // per-consumer slots (manifest component/menu bodies, varDefs defaults).
188
+ assertNoPlaceholderLeaks(context);
147
189
  }
148
190
  export default buildModuleDefs;
@@ -13,22 +13,43 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { ConfigError } from '@lowdefy/errors';
16
- import { serializer, type } from '@lowdefy/helpers';
16
+ import { type } from '@lowdefy/helpers';
17
+ function checkSecretNodes({ value, declaredSecrets, entryId }) {
18
+ if (type.isArray(value)) {
19
+ for (const item of value){
20
+ checkSecretNodes({
21
+ value: item,
22
+ declaredSecrets,
23
+ entryId
24
+ });
25
+ }
26
+ return;
27
+ }
28
+ if (!type.isObject(value)) return;
29
+ // Only a single-non-tilde-key object is operator shape (matches
30
+ // getRuntimeOperatorKey) — { _secret: 'X', extra: 1 } is deliberately not
31
+ // treated as a secret reference.
32
+ const keys = Object.keys(value).filter((k)=>!k.startsWith('~'));
33
+ if (keys.length === 1 && !type.isUndefined(value['_secret'])) {
34
+ const secretName = value['_secret'];
35
+ if (type.isString(secretName) && !declaredSecrets.has(secretName)) {
36
+ throw new ConfigError(`Module "${entryId}" references secret "${secretName}" ` + `but does not declare it in module.lowdefy.yaml secrets. ` + `Add it to the module's secrets list or remove the reference.`);
37
+ }
38
+ }
39
+ for (const key of Object.keys(value)){
40
+ checkSecretNodes({
41
+ value: value[key],
42
+ declaredSecrets,
43
+ entryId
44
+ });
45
+ }
46
+ }
17
47
  function validateModuleSecrets({ content, manifest, entryId }) {
18
48
  const declaredSecrets = new Set((manifest.secrets ?? []).map((s)=>s.name));
19
- serializer.copy(content, {
20
- reviver: (_, value)=>{
21
- if (!type.isObject(value)) return value;
22
- const keys = Object.keys(value).filter((k)=>!k.startsWith('~'));
23
- if (keys.length !== 1) return value;
24
- if (!type.isUndefined(value['_secret'])) {
25
- const secretName = value['_secret'];
26
- if (type.isString(secretName) && !declaredSecrets.has(secretName)) {
27
- throw new ConfigError(`Module "${entryId}" references secret "${secretName}" ` + `but does not declare it in module.lowdefy.yaml secrets. ` + `Add it to the module's secrets list or remove the reference.`);
28
- }
29
- }
30
- return value;
31
- }
49
+ checkSecretNodes({
50
+ value: content,
51
+ declaredSecrets,
52
+ entryId
32
53
  });
33
54
  }
34
55
  function buildModules({ components, context }) {
@@ -71,6 +92,20 @@ function buildModules({ components, context }) {
71
92
  entryId: entry.id
72
93
  });
73
94
  }
95
+ for (const agent of manifest.agents ?? []){
96
+ validateModuleSecrets({
97
+ content: agent,
98
+ manifest,
99
+ entryId: entry.id
100
+ });
101
+ }
102
+ for (const notification of manifest.notifications ?? []){
103
+ validateModuleSecrets({
104
+ content: notification,
105
+ manifest,
106
+ entryId: entry.id
107
+ });
108
+ }
74
109
  // Process pages
75
110
  for (const page of manifest.pages ?? []){
76
111
  page.id = `${entry.id}/${page.id}`;
@@ -90,6 +125,18 @@ function buildModules({ components, context }) {
90
125
  components.api = components.api ?? [];
91
126
  components.api.push(endpoint);
92
127
  }
128
+ // Process agents
129
+ for (const agent of manifest.agents ?? []){
130
+ agent.id = `${entry.id}/${agent.id}`;
131
+ components.agents = components.agents ?? [];
132
+ components.agents.push(agent);
133
+ }
134
+ // Process notifications
135
+ for (const notification of manifest.notifications ?? []){
136
+ notification.id = `${entry.id}/${notification.id}`;
137
+ components.notifications = components.notifications ?? [];
138
+ components.notifications.push(notification);
139
+ }
93
140
  }
94
141
  return components;
95
142
  }
@@ -0,0 +1,98 @@
1
+ /* eslint-disable no-param-reassign */ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ import createCheckDuplicateId from '../utils/createCheckDuplicateId.js';
18
+ import validateId from '../utils/validateId.js';
19
+ function buildNotifications({ components, context }) {
20
+ if (components.notifications && !type.isArray(components.notifications)) {
21
+ throw new ConfigError('Notifications is not an array.', {
22
+ received: components.notifications
23
+ });
24
+ }
25
+ const notifications = type.isArray(components.notifications) ? components.notifications : [];
26
+ context.notificationIds = new Set();
27
+ const checkDuplicateNotificationId = createCheckDuplicateId({
28
+ message: 'Duplicate notificationId "{{ id }}".'
29
+ });
30
+ notifications.forEach((notification, index)=>{
31
+ const configKey = notification['~k'];
32
+ if (type.isUndefined(notification.id)) {
33
+ throw new ConfigError(`Notification id missing at notification ${index}.`, {
34
+ configKey
35
+ });
36
+ }
37
+ if (!type.isString(notification.id)) {
38
+ throw new ConfigError(`Notification id is not a string at notification ${index}.`, {
39
+ received: notification.id,
40
+ configKey
41
+ });
42
+ }
43
+ validateId({
44
+ id: notification.id,
45
+ field: 'Notification id',
46
+ configKey
47
+ });
48
+ checkDuplicateNotificationId({
49
+ id: notification.id,
50
+ configKey
51
+ });
52
+ if (!type.isString(notification.type)) {
53
+ throw new ConfigError(`Notification type is not a string at notification "${notification.id}".`, {
54
+ received: notification.type,
55
+ configKey
56
+ });
57
+ }
58
+ // Track type usage for buildTypes validation
59
+ context.typeCounters.notifications.increment(notification.type, configKey);
60
+ if (type.isNone(notification.properties)) {
61
+ notification.properties = {};
62
+ }
63
+ if (!type.isObject(notification.properties)) {
64
+ throw new ConfigError(`Notification properties is not an object at notification "${notification.id}".`, {
65
+ received: notification.properties,
66
+ configKey
67
+ });
68
+ }
69
+ // subject is a framework contract, independent of the template type's own schema —
70
+ // the render step reads it for the mail header and the returned result.
71
+ if (!type.isString(notification.properties.subject) || notification.properties.subject === '') {
72
+ throw new ConfigError(`Notification "${notification.id}" requires "properties.subject" to be a non-empty string.`, {
73
+ received: notification.properties.subject,
74
+ configKey
75
+ });
76
+ }
77
+ if (!type.isNone(notification.theme) && !type.isObject(notification.theme)) {
78
+ throw new ConfigError(`Notification theme is not an object at notification "${notification.id}".`, {
79
+ received: notification.theme,
80
+ configKey
81
+ });
82
+ }
83
+ if (!type.isNone(notification.testData) && !type.isObject(notification.testData)) {
84
+ throw new ConfigError(`Notification testData is not an object at notification "${notification.id}".`, {
85
+ received: notification.testData,
86
+ configKey
87
+ });
88
+ }
89
+ // Rename id to internal format
90
+ notification.notificationId = notification.id;
91
+ context.notificationIds.add(notification.notificationId);
92
+ notification.id = `notification:${notification.notificationId}`;
93
+ // No countOperators here — notification properties are nunjucks data templates,
94
+ // not operator config; there is no runtime operator evaluation inside them.
95
+ });
96
+ return components;
97
+ }
98
+ export default buildNotifications;
@@ -12,7 +12,8 @@
12
12
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
- */ import buildEvents from './buildEvents.js';
15
+ */ import buildDynamicBlock from './buildDynamicBlock.js';
16
+ import buildEvents from './buildEvents.js';
16
17
  import buildRequests from './buildRequests.js';
17
18
  import buildSubBlocks from './buildSubBlocks.js';
18
19
  import countBlockOperators from './countBlockOperators.js';
@@ -28,6 +29,7 @@ import validateSlots from './validateSlots.js';
28
29
  function buildBlock(block, pageContext, parentConfigKey) {
29
30
  validateBlock(block, pageContext, parentConfigKey);
30
31
  setBlockId(block, pageContext);
32
+ buildDynamicBlock(block, pageContext);
31
33
  normalizeLayout(block, pageContext);
32
34
  moveAreasToSlots(block, pageContext);
33
35
  countBlockOperators(block, pageContext);
@@ -0,0 +1,131 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ import countOperators from '../../../utils/countOperators.js';
18
+ const TYPES_CATEGORIES = new Set([
19
+ 'blocks',
20
+ 'actions',
21
+ 'operators'
22
+ ]);
23
+ function countDeclaredTypes(block, { pageId, typeCounters }) {
24
+ const configKey = block['~k'];
25
+ const types = block.properties.types;
26
+ if (!type.isObject(types)) {
27
+ throw new ConfigError(`Dynamic block "${block.blockId}" on page "${pageId}" properties.types should be an object.`, {
28
+ received: types,
29
+ configKey
30
+ });
31
+ }
32
+ Object.keys(types).forEach((category)=>{
33
+ if (category.startsWith('~')) return;
34
+ if (!TYPES_CATEGORIES.has(category)) {
35
+ throw new ConfigError(`Dynamic block "${block.blockId}" on page "${pageId}" properties.types has unknown category "${category}". Valid categories: blocks, actions, operators.`, {
36
+ configKey
37
+ });
38
+ }
39
+ if (!type.isArray(types[category])) {
40
+ throw new ConfigError(`Dynamic block "${block.blockId}" on page "${pageId}" properties.types.${category} should be an array.`, {
41
+ received: types[category],
42
+ configKey
43
+ });
44
+ }
45
+ types[category].forEach((typeName)=>{
46
+ if (!type.isString(typeName)) {
47
+ throw new ConfigError(`Dynamic block "${block.blockId}" on page "${pageId}" properties.types.${category} should only contain strings.`, {
48
+ received: typeName,
49
+ configKey
50
+ });
51
+ }
52
+ if (category === 'blocks') {
53
+ typeCounters.blocks.increment(typeName, configKey);
54
+ }
55
+ if (category === 'actions') {
56
+ typeCounters.actions.increment(typeName, configKey);
57
+ }
58
+ if (category === 'operators') {
59
+ // Normalize method form (_number.round) to the base operator (_number),
60
+ // matching how countOperators counts operator usage.
61
+ const operator = typeName.split('.')[0];
62
+ if (operator.length < 2 || operator[0] !== '_') {
63
+ throw new ConfigError(`Dynamic block "${block.blockId}" on page "${pageId}" properties.types.operators contains "${typeName}" which is not an operator name. Operator names start with "_".`, {
64
+ configKey
65
+ });
66
+ }
67
+ typeCounters.operators.client.increment(operator, configKey);
68
+ }
69
+ });
70
+ });
71
+ }
72
+ function buildDynamicBlock(block, pageContext) {
73
+ if (block.type !== 'Dynamic') {
74
+ return;
75
+ }
76
+ const { pageId } = pageContext;
77
+ const configKey = block['~k'];
78
+ if (!type.isObject(block.properties)) {
79
+ throw new ConfigError(`Dynamic block "${block.blockId}" on page "${pageId}" properties should be an object.`, {
80
+ received: block.properties,
81
+ configKey
82
+ });
83
+ }
84
+ if (type.isUndefined(block.properties.endpointId)) {
85
+ throw new ConfigError(`Dynamic block "${block.blockId}" on page "${pageId}" requires properties.endpointId.`, {
86
+ configKey
87
+ });
88
+ }
89
+ if (!type.isString(block.properties.endpointId)) {
90
+ throw new ConfigError(`Dynamic block "${block.blockId}" on page "${pageId}" properties.endpointId is not a string.`, {
91
+ received: block.properties.endpointId,
92
+ configKey
93
+ });
94
+ }
95
+ if (!type.isNone(block.properties.params)) {
96
+ if (!type.isObject(block.properties.params)) {
97
+ throw new ConfigError(`Dynamic block "${block.blockId}" on page "${pageId}" properties.params should be an object.`, {
98
+ received: block.properties.params,
99
+ configKey
100
+ });
101
+ }
102
+ // The page artifact is static — params are passed to the endpoint verbatim,
103
+ // so operators here would never evaluate. Runtime values belong in the
104
+ // endpoint routine (_payload, _user, _secret).
105
+ countOperators(block.properties.params, {
106
+ counter: {
107
+ increment: (operator)=>{
108
+ throw new ConfigError(`Dynamic block "${block.blockId}" on page "${pageId}" properties.params must not contain operators. Found "${operator}". Params are static — read runtime values in the endpoint routine with _payload, _user or _secret.`, {
109
+ configKey
110
+ });
111
+ }
112
+ }
113
+ });
114
+ }
115
+ if (!type.isNone(block.properties.required) && !type.isBoolean(block.properties.required)) {
116
+ throw new ConfigError(`Dynamic block "${block.blockId}" on page "${pageId}" properties.required is not a boolean.`, {
117
+ received: block.properties.required,
118
+ configKey
119
+ });
120
+ }
121
+ if (!type.isNone(block.properties.types)) {
122
+ countDeclaredTypes(block, pageContext);
123
+ }
124
+ pageContext.hasDynamicBlocks = true;
125
+ pageContext.dynamicBlockRefs.push({
126
+ endpointId: block.properties.endpointId,
127
+ block,
128
+ sourcePageId: pageId
129
+ });
130
+ }
131
+ export default buildDynamicBlock;