@lowdefy/build 5.5.0 → 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
  }
@@ -0,0 +1,34 @@
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 path from 'path';
16
+ import { type } from '@lowdefy/helpers';
17
+ // Module refs author their path/resolver/transformer relative to the module
18
+ // (e.g. "resolvers/makeActionPages.js"). Rebase those relative paths against the
19
+ // module root so they resolve from the module, not the app config dir. Mutates
20
+ // refDef in place. No-op when moduleRoot is not set or a path is already absolute.
21
+ function rebaseModuleRefPaths({ refDef, moduleRoot }) {
22
+ if (!moduleRoot) return refDef;
23
+ for (const field of [
24
+ 'path',
25
+ 'resolver',
26
+ 'transformer'
27
+ ]){
28
+ if (type.isString(refDef[field]) && !path.isAbsolute(refDef[field])) {
29
+ refDef[field] = path.resolve(moduleRoot, refDef[field]);
30
+ }
31
+ }
32
+ return refDef;
33
+ }
34
+ export default rebaseModuleRefPaths;
@@ -13,10 +13,11 @@
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';
20
+ import rebaseModuleRefPaths from './rebaseModuleRefPaths.js';
20
21
  import getRefContent from './getRefContent.js';
21
22
  import getModuleRefContent from './getModuleRefContent.js';
22
23
  import runTransformer from './runTransformer.js';
@@ -202,19 +203,35 @@ function evaluateBuildOperator(node, ctx) {
202
203
  }
203
204
  return output;
204
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
+ }
205
222
  // Resolve a _var node
206
223
  function resolveVar(node, ctx) {
207
224
  const varDef = node._var;
208
225
  // String form: { _var: "key" }
209
226
  if (type.isString(varDef)) {
210
- const value = get(ctx.vars, varDef, {
227
+ const value = readVar(varDef, ctx, {
211
228
  default: null
212
229
  });
213
230
  return cloneVarValue(value, ctx.sourceRefId);
214
231
  }
215
232
  // Object form: { _var: { key, default } }
216
233
  if (type.isObject(varDef) && type.isString(varDef.key)) {
217
- const varFromParent = get(ctx.vars, varDef.key);
234
+ const varFromParent = readVar(varDef.key, ctx);
218
235
  // Var provided (even if null) → use parent's sourceRefId for location
219
236
  if (!type.isUndefined(varFromParent)) {
220
237
  return cloneVarValue(varFromParent, ctx.sourceRefId);
@@ -470,17 +487,10 @@ async function resolveRef(node, ctx) {
470
487
  refDef.key = await resolve(cloneForResolve(refDef.key), ctx);
471
488
  }
472
489
  // 4. Module path resolution: resolve relative paths from the module root
473
- if (ctx.moduleRoot) {
474
- if (type.isString(refDef.path) && !path.isAbsolute(refDef.path)) {
475
- refDef.path = path.resolve(ctx.moduleRoot, refDef.path);
476
- }
477
- if (type.isString(refDef.resolver) && !path.isAbsolute(refDef.resolver)) {
478
- refDef.resolver = path.resolve(ctx.moduleRoot, refDef.resolver);
479
- }
480
- if (type.isString(refDef.transformer) && !path.isAbsolute(refDef.transformer)) {
481
- refDef.transformer = path.resolve(ctx.moduleRoot, refDef.transformer);
482
- }
483
- }
490
+ rebaseModuleRefPaths({
491
+ refDef,
492
+ moduleRoot: ctx.moduleRoot
493
+ });
484
494
  // 5. Update refMap with resolved path; store original for resolver refs
485
495
  ctx.refMap[refDef.id].path = refDef.path;
486
496
  if (!refDef.path) {
@@ -616,7 +626,8 @@ async function resolveRef(node, ctx) {
616
626
  // 14. Extract key
617
627
  content = getKey({
618
628
  input: content,
619
- refDef
629
+ refDef,
630
+ filePath: ctx.currentFile
620
631
  });
621
632
  // 15. Tag all nodes with ~r for provenance
622
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;
@@ -31,6 +31,7 @@ import evaluateStaticOperators from '../buildRefs/evaluateStaticOperators.js';
31
31
  import getRefContent from '../buildRefs/getRefContent.js';
32
32
  import jsMapParser from '../buildJs/jsMapParser.js';
33
33
  import makeRefDefinition from '../buildRefs/makeRefDefinition.js';
34
+ import rebaseModuleRefPaths from '../buildRefs/rebaseModuleRefPaths.js';
34
35
  import { resolve, WalkContext, cloneForResolve, tagRefDeep } from '../buildRefs/walker.js';
35
36
  import validateOperatorsDynamic from '../validateOperatorsDynamic.js';
36
37
  import writeMaps from '../writeMaps.js';
@@ -151,6 +152,23 @@ async function buildPageJit({ pageId, pageRegistry, context, directories, logger
151
152
  refDef = makeRefDefinition(refDefinition, null, buildContext.refMap);
152
153
  buildContext.refMap[refDef.id].path = refDef.path;
153
154
  }
155
+ // Module path resolution: resolve relative path/resolver/transformer from the
156
+ // module root. The full build does this in walker.js step 4 when an _ref node
157
+ // is encountered, but the JIT path builds the page refDef directly from
158
+ // resolverOriginal (the un-rebased authored _ref) and calls getRefContent
159
+ // without going through the walker — so a module resolver like
160
+ // "resolvers/makeActionPages.js" would otherwise resolve against the app
161
+ // config dir instead of the module root. (File-based module pages are
162
+ // unaffected: their paths are stored already-rebased in refMap.)
163
+ if (moduleEntry?.moduleRoot) {
164
+ rebaseModuleRefPaths({
165
+ refDef,
166
+ moduleRoot: moduleEntry.moduleRoot
167
+ });
168
+ if (type.isString(refDef.path)) {
169
+ buildContext.refMap[refDef.id].path = refDef.path;
170
+ }
171
+ }
154
172
  const pageContent = await getRefContent({
155
173
  context: buildContext,
156
174
  refDef,
@@ -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
  ]);