@lowdefy/build 5.5.1 → 5.6.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.
@@ -14,7 +14,7 @@
14
14
  limitations under the License.
15
15
  */ import path from 'path';
16
16
  import fs from 'fs';
17
- import { type } from '@lowdefy/helpers';
17
+ import { isReserved, type } from '@lowdefy/helpers';
18
18
  import { ConfigError, ConfigWarning } from '@lowdefy/errors';
19
19
  import { RESERVED_PLATFORM_TOOL_NAMES } from '@lowdefy/ai-utils';
20
20
  import countOperators from '../utils/countOperators.js';
@@ -56,6 +56,14 @@ function buildAgents({ components, context }) {
56
56
  });
57
57
  components.agents.forEach((agent)=>{
58
58
  const configKey = agent['~k'];
59
+ // agent.id becomes agent.agentId, which keys the detectCycles graph and the
60
+ // runtime agent registry - both plain objects. Reject a reserved name here,
61
+ // where the config location is still in hand.
62
+ if (isReserved(agent.id)) {
63
+ throw new ConfigError(`Agent id "${agent.id}" is a reserved name and cannot be used as an id.`, {
64
+ configKey
65
+ });
66
+ }
59
67
  // Check duplicates
60
68
  checkDuplicateAgentId({
61
69
  id: agent.id,
@@ -0,0 +1,56 @@
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 getAgentRoles from './getAgentRoles.js';
18
+ import getProtectedAgents from './getProtectedAgents.js';
19
+ import { isInPatternList } from './matchPattern.js';
20
+ // Agents are served from the API surface, so auth.api patterns match agent ids too.
21
+ function buildAgentAuth({ components, context }) {
22
+ const protectedAgents = getProtectedAgents({
23
+ components
24
+ });
25
+ const agentRoles = getAgentRoles({
26
+ components
27
+ });
28
+ let configPublicApi = [];
29
+ if (type.isArray(components.auth.api.public)) {
30
+ configPublicApi = components.auth.api.public;
31
+ }
32
+ (components.agents ?? []).forEach((agent)=>{
33
+ if (agentRoles[agent.id]) {
34
+ if (isInPatternList(agent.id, configPublicApi)) {
35
+ throw new ConfigError(`Agent "${agent.id}" is both protected by roles and public.`, {
36
+ received: agentRoles[agent.id],
37
+ configKey: agent['~k']
38
+ });
39
+ }
40
+ agent.auth = {
41
+ public: false,
42
+ roles: agentRoles[agent.id]
43
+ };
44
+ } else if (protectedAgents.includes(agent.id)) {
45
+ agent.auth = {
46
+ public: false
47
+ };
48
+ } else {
49
+ agent.auth = {
50
+ public: true
51
+ };
52
+ }
53
+ });
54
+ return components;
55
+ }
56
+ export default buildAgentAuth;
@@ -14,8 +14,10 @@
14
14
  limitations under the License.
15
15
  */ import { type } from '@lowdefy/helpers';
16
16
  import buildAuthPlugins from './buildAuthPlugins.js';
17
+ import buildAgentAuth from './buildAgentAuth.js';
17
18
  import buildApiAuth from './buildApiAuth.js';
18
19
  import buildPageAuth from './buildPageAuth.js';
20
+ import rejectReservedEntityIds from './rejectReservedEntityIds.js';
19
21
  import validateAuthConfig from './validateAuthConfig.js';
20
22
  function buildAuth({ components, context }) {
21
23
  const configured = !type.isNone(components.auth);
@@ -23,11 +25,18 @@ function buildAuth({ components, context }) {
23
25
  components,
24
26
  context
25
27
  });
28
+ rejectReservedEntityIds({
29
+ components
30
+ });
26
31
  components.auth.configured = configured;
27
32
  buildApiAuth({
28
33
  components,
29
34
  context
30
35
  });
36
+ buildAgentAuth({
37
+ components,
38
+ context
39
+ });
31
40
  buildPageAuth({
32
41
  components,
33
42
  context
@@ -0,0 +1,39 @@
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 { matchesPattern } from './matchPattern.js';
16
+ function getAgentRoles({ components }) {
17
+ const roles = components.auth.api.roles;
18
+ const agentIds = (components.agents ?? []).map((agent)=>agent.id);
19
+ const agentRoles = {};
20
+ Object.keys(roles).forEach((roleName)=>{
21
+ roles[roleName].forEach((pattern)=>{
22
+ agentIds.forEach((agentId)=>{
23
+ if (matchesPattern(agentId, pattern)) {
24
+ if (!agentRoles[agentId]) {
25
+ agentRoles[agentId] = new Set();
26
+ }
27
+ agentRoles[agentId].add(roleName);
28
+ }
29
+ });
30
+ });
31
+ });
32
+ Object.keys(agentRoles).forEach((agentId)=>{
33
+ agentRoles[agentId] = [
34
+ ...agentRoles[agentId]
35
+ ];
36
+ });
37
+ return agentRoles;
38
+ }
39
+ export default getAgentRoles;
@@ -0,0 +1,29 @@
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 { isInPatternList } from './matchPattern.js';
17
+ function getProtectedAgents({ components }) {
18
+ const agentIds = (components.agents ?? []).map((agent)=>agent.id);
19
+ let protectedAgents = [];
20
+ if (type.isArray(components.auth.api.public)) {
21
+ protectedAgents = agentIds.filter((agentId)=>!isInPatternList(agentId, components.auth.api.public));
22
+ } else if (components.auth.api.protected === true) {
23
+ protectedAgents = agentIds;
24
+ } else if (type.isArray(components.auth.api.protected)) {
25
+ protectedAgents = agentIds.filter((agentId)=>isInPatternList(agentId, components.auth.api.protected));
26
+ }
27
+ return protectedAgents;
28
+ }
29
+ export default getProtectedAgents;
@@ -0,0 +1,52 @@
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 { isReserved } from '@lowdefy/helpers';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ // buildAuth runs before validateId sees page, endpoint and agent ids, and it keys
18
+ // plain-object role maps on them: getPageRoles, getApiRoles and getAgentRoles all
19
+ // build `roles[id]` and read it back with a truthiness check. A reserved id reads
20
+ // through Object.prototype there, so the entity is silently marked protected with
21
+ // Object.prototype as its roles - and the object then travels into addKeys, which
22
+ // stamps a '~k' onto Object.prototype and corrupts every plain object in the
23
+ // process. Gate the ids here, where buildAuth first touches them.
24
+ //
25
+ // validateId reports the same ids again in buildApi, buildAgents and buildPages;
26
+ // logCollectedErrors collapses the pair on source line plus message so the author
27
+ // reads it once.
28
+ function rejectReservedEntityIds({ components }) {
29
+ const entityTypes = [
30
+ {
31
+ items: components.pages,
32
+ field: 'Page id'
33
+ },
34
+ {
35
+ items: components.api,
36
+ field: 'Endpoint id'
37
+ },
38
+ {
39
+ items: components.agents,
40
+ field: 'Agent id'
41
+ }
42
+ ];
43
+ entityTypes.forEach(({ items, field })=>{
44
+ (items ?? []).forEach((item)=>{
45
+ if (!isReserved(item.id)) return;
46
+ throw new ConfigError(`${field} "${item.id}" is a reserved name and cannot be used as an id.`, {
47
+ configKey: item['~k']
48
+ });
49
+ });
50
+ });
51
+ }
52
+ export default rejectReservedEntityIds;
@@ -12,7 +12,7 @@
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 { type } from '@lowdefy/helpers';
15
+ */ import { isReserved, type } from '@lowdefy/helpers';
16
16
  import { ConfigError, ConfigWarning } from '@lowdefy/errors';
17
17
  import createCheckDuplicateId from '../../../utils/createCheckDuplicateId.js';
18
18
  const BROWSER_DEFAULT_SHORTCUTS = new Set([
@@ -175,6 +175,16 @@ function buildEvents(block, pageContext) {
175
175
  configKey: eventConfigKey
176
176
  });
177
177
  }
178
+ // The client's shortcut manager keys a plain object by the normalized
179
+ // shortcut. Normalization neither creates nor removes a reserved name:
180
+ // multi-character key names pass through unchanged, and no reserved
181
+ // name is a single character. So checking the raw string is enough, and
182
+ // a modified form like "Ctrl+__proto__" stays valid.
183
+ if (isReserved(shortcut)) {
184
+ throw new ConfigError(`Event shortcut "${shortcut}" on event "${key}" on block "${block.blockId}" on page "${pageContext.pageId}" is a reserved name and cannot be used as a shortcut.`, {
185
+ configKey: eventConfigKey
186
+ });
187
+ }
178
188
  if (BROWSER_DEFAULT_SHORTCUTS.has(shortcut.toLowerCase())) {
179
189
  pageContext.context.handleWarning(new ConfigWarning(`Shortcut "${shortcut}" on event "${key}" on block "${block.blockId}" on page "${pageContext.pageId}" conflicts with a browser default.`, {
180
190
  configKey: eventConfigKey
@@ -12,7 +12,7 @@
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 { type } from '@lowdefy/helpers';
15
+ */ import { isReserved, splitPath, type } from '@lowdefy/helpers';
16
16
  import { ConfigError } from '@lowdefy/errors';
17
17
  function validateBlock(block, { pageId }, parentConfigKey) {
18
18
  const configKey = block?.['~k'] ?? parentConfigKey;
@@ -33,6 +33,17 @@ function validateBlock(block, { pageId }, parentConfigKey) {
33
33
  configKey
34
34
  });
35
35
  }
36
+ // Block ids are dot-paths that nest state, so they bypass validateId's shape gate. Every segment
37
+ // still becomes an object key on the way through get/set, so check per segment: a block
38
+ // `id: a.constructor.b` would reach `get` with a reserved segment and throw mid-render, which
39
+ // ErrorBoundary cannot catch because the throw happens during the render that would mount it.
40
+ const reservedSegment = splitPath(block.id).find(isReserved);
41
+ if (!type.isNone(reservedSegment)) {
42
+ throw new ConfigError(`Block id "${block.id}" uses reserved name "${reservedSegment}" as a path segment, at page "${pageId}".`, {
43
+ received: block.id,
44
+ configKey
45
+ });
46
+ }
36
47
  if (type.isNone(block.type)) {
37
48
  throw new ConfigError(`Block type is not defined at "${block.id}" on page "${pageId}".`, {
38
49
  configKey
@@ -12,12 +12,23 @@
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 { get } from '@lowdefy/helpers';
16
- function getKey({ input, refDef }) {
15
+ */ import { get, ReservedKeyError } from '@lowdefy/helpers';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ function getKey({ input, refDef, filePath }) {
17
18
  if (refDef.key) {
18
- return get(input, refDef.key, {
19
- default: null
20
- });
19
+ try {
20
+ return get(input, refDef.key, {
21
+ default: null
22
+ });
23
+ } catch (error) {
24
+ if (!(error instanceof ReservedKeyError)) throw error;
25
+ // The ref key is author-written YAML, so a reserved name is a config mistake. Without
26
+ // this the bare ReservedKeyError reaches the build with no file and no line.
27
+ throw new ConfigError(`_ref key "${refDef.key}" is a reserved name.`, {
28
+ cause: error,
29
+ filePath
30
+ });
31
+ }
21
32
  }
22
33
  return input;
23
34
  }
@@ -13,7 +13,7 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import path from 'path';
16
- import { get, serializer, type } from '@lowdefy/helpers';
16
+ import { get, ReservedKeyError, serializer, type } from '@lowdefy/helpers';
17
17
  import { ConfigError } from '@lowdefy/errors';
18
18
  import { evaluateOperators } from '@lowdefy/operators';
19
19
  import makeRefDefinition from './makeRefDefinition.js';
@@ -203,19 +203,35 @@ function evaluateBuildOperator(node, ctx) {
203
203
  }
204
204
  return output;
205
205
  }
206
+ // _var keys are author-written YAML, so a reserved name (e.g. "constructor") is a config
207
+ // mistake, not a missing var. Wraps both call shapes for that check. The object form call
208
+ // site below passes no `options` argument at all, so a missing key comes back from `get` as
209
+ // `undefined` instead of a default — resolveVar relies on that `undefined` to tell "var not
210
+ // provided" apart from "var provided as null".
211
+ function readVar(key, ctx, options) {
212
+ try {
213
+ return get(ctx.vars, key, options);
214
+ } catch (error) {
215
+ if (!(error instanceof ReservedKeyError)) throw error;
216
+ throw new ConfigError(`_var key "${key}" is a reserved name.`, {
217
+ cause: error,
218
+ filePath: ctx.currentFile
219
+ });
220
+ }
221
+ }
206
222
  // Resolve a _var node
207
223
  function resolveVar(node, ctx) {
208
224
  const varDef = node._var;
209
225
  // String form: { _var: "key" }
210
226
  if (type.isString(varDef)) {
211
- const value = get(ctx.vars, varDef, {
227
+ const value = readVar(varDef, ctx, {
212
228
  default: null
213
229
  });
214
230
  return cloneVarValue(value, ctx.sourceRefId);
215
231
  }
216
232
  // Object form: { _var: { key, default } }
217
233
  if (type.isObject(varDef) && type.isString(varDef.key)) {
218
- const varFromParent = get(ctx.vars, varDef.key);
234
+ const varFromParent = readVar(varDef.key, ctx);
219
235
  // Var provided (even if null) → use parent's sourceRefId for location
220
236
  if (!type.isUndefined(varFromParent)) {
221
237
  return cloneVarValue(varFromParent, ctx.sourceRefId);
@@ -610,7 +626,8 @@ async function resolveRef(node, ctx) {
610
626
  // 14. Extract key
611
627
  content = getKey({
612
628
  input: content,
613
- refDef
629
+ refDef,
630
+ filePath: ctx.currentFile
614
631
  });
615
632
  // 15. Tag all nodes with ~r for provenance
616
633
  tagRefDeep(content, refDef.id);
@@ -14,6 +14,7 @@
14
14
  limitations under the License.
15
15
  */ import fs from 'node:fs';
16
16
  import path from 'node:path';
17
+ import { ReservedKeyError, setKey } from '@lowdefy/helpers';
17
18
  import { ConfigError } from '@lowdefy/errors';
18
19
  import fetchGitHubModule from './fetchGitHubModule.js';
19
20
  import parseModuleSource from './parseModuleSource.js';
@@ -28,6 +29,19 @@ function findGitRoot(startPath) {
28
29
  dir = parent;
29
30
  }
30
31
  }
32
+ // Module ids come from the app's lowdefy.yaml, so they key this map from user
33
+ // config. Surface a reserved id as the build's ConfigError rather than a bare
34
+ // ReservedKeyError.
35
+ function setResolvedPaths(resolved, id, paths) {
36
+ try {
37
+ setKey(resolved, id, paths);
38
+ } catch (error) {
39
+ if (!(error instanceof ReservedKeyError)) throw error;
40
+ throw new ConfigError(`Module entry id "${id}" is a reserved name.`, {
41
+ cause: error
42
+ });
43
+ }
44
+ }
31
45
  async function fetchModules({ moduleEntries, context }) {
32
46
  const resolved = {};
33
47
  for (const entry of moduleEntries){
@@ -37,22 +51,22 @@ async function fetchModules({ moduleEntries, context }) {
37
51
  if (!fs.existsSync(path.join(resolvedPath, 'module.lowdefy.yaml'))) {
38
52
  throw new ConfigError(`Module "${entry.id}": module.lowdefy.yaml not found at ${resolvedPath}`);
39
53
  }
40
- resolved[entry.id] = {
54
+ setResolvedPaths(resolved, entry.id, {
41
55
  packageRoot: findGitRoot(resolvedPath) ?? resolvedPath,
42
56
  moduleRoot: resolvedPath,
43
57
  isLocal: true
44
- };
58
+ });
45
59
  } else if (source.type === 'github') {
46
60
  const cached = await fetchGitHubModule(source, context);
47
61
  const moduleRoot = source.path ? path.join(cached.packageRoot, source.path) : cached.packageRoot;
48
62
  if (!fs.existsSync(path.join(moduleRoot, 'module.lowdefy.yaml'))) {
49
63
  throw new ConfigError(`Module "${entry.id}": module.lowdefy.yaml not found at path "${source.path || '/'}" in ${source.owner}/${source.repo}@${source.ref}`);
50
64
  }
51
- resolved[entry.id] = {
65
+ setResolvedPaths(resolved, entry.id, {
52
66
  packageRoot: cached.packageRoot,
53
67
  moduleRoot,
54
68
  isLocal: false
55
- };
69
+ });
56
70
  }
57
71
  }
58
72
  return resolved;
@@ -14,7 +14,7 @@
14
14
  limitations under the License.
15
15
  */ import path from 'path';
16
16
  import semver from 'semver';
17
- import { type } from '@lowdefy/helpers';
17
+ import { ReservedKeyError, setKey, type } from '@lowdefy/helpers';
18
18
  import { ConfigError } from '@lowdefy/errors';
19
19
  import operators from '@lowdefy/operators-js/operators/build';
20
20
  import { resolve, WalkContext } from './buildRefs/walker.js';
@@ -69,9 +69,6 @@ async function resolveLocalManifest({ entry, resolvedPaths, context }) {
69
69
  if (entry.id.includes('/')) {
70
70
  throw new ConfigError(`Module entry id "${entry.id}" must not contain '/'. ` + `Use a flat identifier like "team-users".`);
71
71
  }
72
- if (entry.id === '__proto__' || entry.id === 'constructor' || entry.id === 'prototype') {
73
- throw new ConfigError(`Module entry id "${entry.id}" is a reserved name.`);
74
- }
75
72
  if (!entry.source || !type.isString(entry.source)) {
76
73
  throw new ConfigError(`Module entry "${entry.id}": 'source' is required and must be a string.`);
77
74
  }
@@ -151,21 +148,30 @@ async function resolveLocalManifest({ entry, resolvedPaths, context }) {
151
148
  throw new ConfigError(`Module "${entry.id}" requires plugin "${plugin.name}" version "${plugin.version}" ` + `but the app has version "${appVersion}" installed. ` + `Update the plugin to a compatible version.`);
152
149
  }
153
150
  }
154
- context.modules[entry.id] = {
155
- id: entry.id,
156
- source: entry.source,
157
- packageRoot,
158
- moduleRoot,
159
- isLocal,
160
- consumerVars: entry.vars ?? {},
161
- varDefs,
162
- resolvedVarCache: {},
163
- connections: entry.connections ?? {},
164
- manifest,
165
- dependencies,
166
- moduleDependencies: entry.dependencies ?? {},
167
- refDef
168
- };
151
+ try {
152
+ setKey(context.modules, entry.id, {
153
+ id: entry.id,
154
+ source: entry.source,
155
+ packageRoot,
156
+ moduleRoot,
157
+ isLocal,
158
+ consumerVars: entry.vars ?? {},
159
+ varDefs,
160
+ resolvedVarCache: {},
161
+ connections: entry.connections ?? {},
162
+ manifest,
163
+ dependencies,
164
+ moduleDependencies: entry.dependencies ?? {},
165
+ refDef
166
+ });
167
+ } catch (error) {
168
+ if (error instanceof ReservedKeyError) {
169
+ throw new ConfigError(`Module entry id "${entry.id}" is a reserved name.`, {
170
+ cause: error
171
+ });
172
+ }
173
+ throw error;
174
+ }
169
175
  }
170
176
  async function resolveFullManifest({ entryId, context }) {
171
177
  const moduleEntry = context.modules[entryId];
@@ -13,7 +13,7 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { ConfigError, ConfigWarning } from '@lowdefy/errors';
16
- import { mergeObjects, serializer, type } from '@lowdefy/helpers';
16
+ import { isReserved, mergeObjects, serializer, type } from '@lowdefy/helpers';
17
17
  function collectPluginMessages({ messagesMap, declaredCodes }) {
18
18
  const collected = {};
19
19
  Object.values(messagesMap ?? {}).forEach((pluginCatalog)=>{
@@ -57,6 +57,13 @@ async function writeI18n({ components, context }) {
57
57
  configKey: locale?.['~k'] ?? i18n['~k']
58
58
  });
59
59
  }
60
+ // The code keys the collected plugin catalog and the merged messages
61
+ // artifact, both plain objects.
62
+ if (isReserved(locale.code)) {
63
+ throw new ConfigError(`Locale code "${locale.code}" in "config.i18n.locales" is a reserved name and cannot be used as a locale code.`, {
64
+ configKey: locale['~k']
65
+ });
66
+ }
60
67
  if (codes.has(locale.code)) {
61
68
  throw new ConfigError(`Duplicate locale code "${locale.code}" in "config.i18n.locales".`, {
62
69
  configKey: locale['~k']
@@ -62,7 +62,6 @@ function objectToThemeVars(obj, prefix) {
62
62
  }
63
63
  function buildThemeVars(tailwindConfig) {
64
64
  const merged = mergeObjects([
65
- {},
66
65
  BRIDGE_DEFAULTS,
67
66
  tailwindConfig ?? {}
68
67
  ]);