@lowdefy/build 6.0.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.
@@ -27,7 +27,7 @@ function buildApi({ components, context }) {
27
27
  message: 'Duplicate endpointId "{{ id }}".'
28
28
  });
29
29
  // Validated by validateConfig, which runs before buildApi.
30
- const cronEnvironments = components.config?.cron?.environments;
30
+ const environments = components.config?.environments;
31
31
  // Wrap each endpoint build to collect errors instead of stopping on first error
32
32
  api.forEach((endpoint, index)=>{
33
33
  try {
@@ -36,7 +36,7 @@ function buildApi({ components, context }) {
36
36
  index,
37
37
  context,
38
38
  checkDuplicateEndpointId,
39
- cronEnvironments
39
+ environments
40
40
  });
41
41
  } catch (error) {
42
42
  // Skip suppressed ConfigErrors (via ~ignoreBuildChecks)
@@ -16,17 +16,17 @@
16
16
  import resolveEndpointSchedules from './resolveEndpointSchedules.js';
17
17
  import validateEndpoint from './validateEndpoint.js';
18
18
  import validateStepReferences from './validateStepReferences.js';
19
- function buildEndpoint({ endpoint, index, context, checkDuplicateEndpointId, cronEnvironments }) {
19
+ function buildEndpoint({ endpoint, index, context, checkDuplicateEndpointId, environments }) {
20
20
  validateEndpoint({
21
21
  endpoint,
22
22
  index,
23
23
  checkDuplicateEndpointId,
24
- cronEnvironments
24
+ environments
25
25
  });
26
26
  endpoint.endpointId = endpoint.id;
27
27
  resolveEndpointSchedules({
28
28
  endpoint,
29
- cronEnvironments
29
+ environments
30
30
  });
31
31
  buildRoutine(endpoint.routine, {
32
32
  endpointId: endpoint.endpointId,
@@ -13,24 +13,27 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { type } from '@lowdefy/helpers';
16
- import getCronEnvironmentNames from '../../utils/getCronEnvironmentNames.js';
17
- // With config.cron.environments declared, the artifact carries `schedules` as an object with one
16
+ import getEnvironmentNames from '../../utils/getEnvironmentNames.js';
17
+ // With config.environments declared, the artifact carries `schedules` as an object with one
18
18
  // explicit list per declared environment: the environment's own entry when the author keyed the
19
19
  // schedules by environment, else the `default` entry, else nothing (`[]` turns the crons off). An
20
- // array applies as-is to every environment. The runtime and the schedules manifest then read
21
- // `schedules.<environment>` without repeating the inheritance. Without config.cron the endpoint
22
- // keeps its array.
23
- function resolveEndpointSchedules({ endpoint, cronEnvironments }) {
24
- const names = getCronEnvironmentNames(cronEnvironments);
20
+ // array applies as-is to every environment. `default` is kept too, for a build with no current
21
+ // environment. The runtime and the schedules manifest then read `schedules.<environment>` without
22
+ // repeating the inheritance. Without config.environments the endpoint keeps its array.
23
+ function resolveEndpointSchedules({ endpoint, environments }) {
24
+ const names = getEnvironmentNames(environments);
25
25
  if (names.length === 0 || type.isUndefined(endpoint.schedules)) return;
26
26
  const authored = endpoint.schedules;
27
- const resolved = {};
27
+ const inherited = type.isArray(authored) ? authored : authored.default ?? [];
28
+ const resolved = {
29
+ default: inherited
30
+ };
28
31
  names.forEach((name)=>{
29
32
  if (type.isArray(authored)) {
30
33
  resolved[name] = authored;
31
34
  return;
32
35
  }
33
- resolved[name] = authored[name] ?? authored.default ?? [];
36
+ resolved[name] = authored[name] ?? inherited;
34
37
  });
35
38
  endpoint.schedules = resolved;
36
39
  }
@@ -16,7 +16,7 @@
16
16
  import { ConfigError } from '@lowdefy/errors';
17
17
  import validateId from '../../utils/validateId.js';
18
18
  import validateCronExpression from '../../utils/validateCronExpression.js';
19
- import getCronEnvironmentNames from '../../utils/getCronEnvironmentNames.js';
19
+ import getEnvironmentNames from '../../utils/getEnvironmentNames.js';
20
20
  // `where` names the schedules list in messages: "" for the endpoint's own schedules, or
21
21
  // ` for environment "staging"` for an override.
22
22
  function validateSchedules({ schedules, endpoint, configKey, where = '' }) {
@@ -66,18 +66,18 @@ function validateSchedules({ schedules, endpoint, configKey, where = '' }) {
66
66
  });
67
67
  }
68
68
  // The object form of `schedules`: lists keyed by environment name plus an optional `default` the
69
- // other environments inherit. Every key must be declared in config.cron.environments, so a typo
69
+ // other environments inherit. Every key must be declared in config.environments, so a typo
70
70
  // cannot silently leave an environment on the defaults.
71
- function validateEnvironmentSchedules({ endpoint, configKey, cronEnvironments }) {
72
- const declared = getCronEnvironmentNames(cronEnvironments);
71
+ function validateEnvironmentSchedules({ endpoint, configKey, environments }) {
72
+ const declared = getEnvironmentNames(environments);
73
73
  if (declared.length === 0) {
74
- throw new ConfigError(`Endpoint "${endpoint.id}" keys schedules by environment but lowdefy.config.cron.environments is not defined.`, {
74
+ throw new ConfigError(`Endpoint "${endpoint.id}" keys schedules by environment but config.environments is not defined.`, {
75
75
  configKey
76
76
  });
77
77
  }
78
- getCronEnvironmentNames(endpoint.schedules).forEach((name)=>{
78
+ getEnvironmentNames(endpoint.schedules).forEach((name)=>{
79
79
  if (name !== 'default' && !declared.includes(name)) {
80
- throw new ConfigError(`Endpoint schedules environment "${name}" at "${endpoint.id}" is not declared in lowdefy.config.cron.environments. Declared environments: ${declared.join(', ')}.`, {
80
+ throw new ConfigError(`Endpoint schedules environment "${name}" at "${endpoint.id}" is not declared in config.environments. Declared environments: ${declared.join(', ')}.`, {
81
81
  configKey
82
82
  });
83
83
  }
@@ -97,7 +97,7 @@ function validateEnvironmentSchedules({ endpoint, configKey, cronEnvironments })
97
97
  });
98
98
  });
99
99
  }
100
- function validateEndpoint({ endpoint, index, checkDuplicateEndpointId, cronEnvironments }) {
100
+ function validateEndpoint({ endpoint, index, checkDuplicateEndpointId, environments }) {
101
101
  const configKey = endpoint['~k'];
102
102
  if (type.isUndefined(endpoint.id)) {
103
103
  throw new ConfigError(`Endpoint id missing at endpoint ${index}.`, {
@@ -144,7 +144,7 @@ function validateEndpoint({ endpoint, index, checkDuplicateEndpointId, cronEnvir
144
144
  validateEnvironmentSchedules({
145
145
  endpoint,
146
146
  configKey,
147
- cronEnvironments
147
+ environments
148
148
  });
149
149
  } else {
150
150
  validateSchedules({
@@ -13,6 +13,7 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { execSync } from 'child_process';
16
+ import { randomUUID } from 'crypto';
16
17
  import { ConfigError } from '@lowdefy/errors';
17
18
  import { type } from '@lowdefy/helpers';
18
19
  import { evaluateOperators } from '@lowdefy/operators';
@@ -123,7 +124,14 @@ async function buildAppMeta({ context }) {
123
124
  field: 'lowdefy',
124
125
  value: content.lowdefy
125
126
  }),
126
- gitSha: computeGitSha()
127
+ gitSha: computeGitSha(),
128
+ // Identity of this build, unique per run. The client bundle and the server
129
+ // both read it from appMeta.json, so an open tab can tell after a deploy
130
+ // that the page config it just fetched came from a newer build than the
131
+ // bundle it is running, and reload instead of failing on missing _js
132
+ // functions. gitSha is not enough: it is null on most deploy platforms and
133
+ // the same commit can be rebuilt with different config.
134
+ buildId: randomUUID()
127
135
  };
128
136
  return context.appMeta;
129
137
  }
@@ -0,0 +1,261 @@
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, ConfigWarning } from '@lowdefy/errors';
17
+ import checkEnvironmentGuards from '@lowdefy/node-utils/checkEnvironmentGuards.js';
18
+ import getEnvironmentNames from '../utils/getEnvironmentNames.js';
19
+ import validateEnvironmentGuards from './validateEnvironmentGuards.js';
20
+ // Environment names become a path segment (/api/cron-forward/<environment>/<endpointId>).
21
+ const environmentNamePattern = /^[A-Za-z0-9\-_]+$/;
22
+ // Features an environment can switch off with `<feature>.enabled: false`. Logging has no switch.
23
+ const switchableFeatures = [
24
+ 'cron',
25
+ 'email',
26
+ 'sentry'
27
+ ];
28
+ function isAbsoluteHttpUrl(value) {
29
+ try {
30
+ const url = new URL(value);
31
+ return url.protocol === 'http:' || url.protocol === 'https:';
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+ function validateEmailFilter({ name, filter, configKey }) {
37
+ const where = `config.environments.${name}.email.filter`;
38
+ if (!type.isObject(filter)) {
39
+ throw new ConfigError(`App "${where}" should be an object.`, {
40
+ received: filter,
41
+ configKey
42
+ });
43
+ }
44
+ const { replaceAddress, allowlist, regex } = filter;
45
+ if (!type.isNone(replaceAddress) && (!type.isString(replaceAddress) || replaceAddress === '')) {
46
+ throw new ConfigError(`App "${where}.replaceAddress" should be a non-empty string.`, {
47
+ received: replaceAddress,
48
+ configKey
49
+ });
50
+ }
51
+ if (!type.isNone(allowlist) && (!type.isArray(allowlist) || !allowlist.every((domain)=>type.isString(domain)))) {
52
+ throw new ConfigError(`App "${where}.allowlist" should be an array of strings.`, {
53
+ received: allowlist,
54
+ configKey
55
+ });
56
+ }
57
+ if (!type.isNone(regex)) {
58
+ if (!type.isString(regex)) {
59
+ throw new ConfigError(`App "${where}.regex" should be a string.`, {
60
+ received: regex,
61
+ configKey
62
+ });
63
+ }
64
+ // Compiled here so a broken pattern fails the build, not every mail request in the environment.
65
+ try {
66
+ new RegExp(regex);
67
+ } catch (error) {
68
+ throw new ConfigError(`App "${where}.regex" is not a valid regular expression: ${error.message}`, {
69
+ configKey
70
+ });
71
+ }
72
+ }
73
+ }
74
+ function validateEnvironment({ name, environment, configKey }) {
75
+ if (name === 'default') {
76
+ throw new ConfigError('Environment name "default" is reserved: it names the value every other environment inherits.', {
77
+ configKey
78
+ });
79
+ }
80
+ if (!environmentNamePattern.test(name)) {
81
+ throw new ConfigError(`Environment name "${name}" is invalid. Environment names must only contain A-Z, a-z, 0-9, "-" and "_".`, {
82
+ configKey
83
+ });
84
+ }
85
+ if (!type.isObject(environment)) {
86
+ throw new ConfigError(`App "config.environments.${name}" should be an object.`, {
87
+ received: environment,
88
+ configKey
89
+ });
90
+ }
91
+ const key = environment['~k'] ?? configKey;
92
+ const { url, cron, email, sentry } = environment;
93
+ if (!type.isUndefined(url) && (!type.isString(url) || !isAbsoluteHttpUrl(url))) {
94
+ throw new ConfigError(`App "config.environments.${name}.url" should be an absolute http(s) URL, e.g. "https://staging.example.com".`, {
95
+ received: url,
96
+ configKey: key
97
+ });
98
+ }
99
+ if (!type.isUndefined(cron)) {
100
+ if (!type.isObject(cron)) {
101
+ throw new ConfigError(`App "config.environments.${name}.cron" should be an object.`, {
102
+ received: cron,
103
+ configKey: key
104
+ });
105
+ }
106
+ if (!type.isUndefined(cron.secret)) {
107
+ if (!type.isString(cron.secret) || cron.secret === '') {
108
+ throw new ConfigError(`App "config.environments.${name}.cron.secret" should be the name of the Lowdefy secret holding the environment's CRON_SECRET.`, {
109
+ received: cron.secret,
110
+ configKey: cron['~k'] ?? key
111
+ });
112
+ }
113
+ if (type.isUndefined(url)) {
114
+ throw new ConfigError(`Environment "${name}" has a cron.secret but no url. Crons are forwarded to an environment's url, so set the environment's deployment origin.`, {
115
+ configKey: key
116
+ });
117
+ }
118
+ }
119
+ }
120
+ if (!type.isUndefined(sentry) && !type.isObject(sentry)) {
121
+ throw new ConfigError(`App "config.environments.${name}.sentry" should be an object.`, {
122
+ received: sentry,
123
+ configKey: key
124
+ });
125
+ }
126
+ switchableFeatures.forEach((feature)=>{
127
+ const enabled = environment[feature]?.enabled;
128
+ if (!type.isUndefined(enabled) && !type.isBoolean(enabled)) {
129
+ throw new ConfigError(`App "config.environments.${name}.${feature}.enabled" should be a boolean.`, {
130
+ received: enabled,
131
+ configKey: environment[feature]['~k'] ?? key
132
+ });
133
+ }
134
+ });
135
+ validateEnvironmentGuards({
136
+ name,
137
+ guards: environment.guards,
138
+ configKey: key
139
+ });
140
+ if (!type.isUndefined(email)) {
141
+ if (!type.isObject(email)) {
142
+ throw new ConfigError(`App "config.environments.${name}.email" should be an object.`, {
143
+ received: email,
144
+ configKey: key
145
+ });
146
+ }
147
+ if (!type.isUndefined(email.filter)) {
148
+ validateEmailFilter({
149
+ name,
150
+ filter: email.filter,
151
+ configKey: email['~k'] ?? key
152
+ });
153
+ }
154
+ }
155
+ }
156
+ // LOWDEFY_ENVIRONMENT wins over config.environment: the variable is set per deployment, while a
157
+ // literal in config is shared by every deployment and would force them all into one environment.
158
+ function getCurrentEnvironmentName({ config }) {
159
+ if (!type.isNone(config.environment)) {
160
+ if (!type.isString(config.environment) || config.environment === '') {
161
+ throw new ConfigError('App "config.environment" should be a non-empty string.', {
162
+ received: config.environment,
163
+ configKey: config['~k']
164
+ });
165
+ }
166
+ }
167
+ const fromEnv = process.env.LOWDEFY_ENVIRONMENT?.trim();
168
+ if (!type.isNone(fromEnv) && fromEnv !== '') return fromEnv;
169
+ return config.environment ?? undefined;
170
+ }
171
+ // Resolves the deployment environments once for the whole build. After this step:
172
+ // - config.environments holds every declared environment ({ url, cron, email }), validated;
173
+ // - config.environment names the environment this build is for (the LOWDEFY_ENVIRONMENT
174
+ // variable, else config.environment), and must be declared when environments are.
175
+ // Everything environment-specific (cron registration and forwarding, notification links, the auth
176
+ // base URL, the email delivery filter, the Sentry environment) reads the current environment from
177
+ // there instead of from its own environment variable. buildLogger, which runs next, applies the
178
+ // Sentry settings.
179
+ function buildEnvironments({ components, context }) {
180
+ const config = components.config;
181
+ const configKey = config['~k'];
182
+ const current = getCurrentEnvironmentName({
183
+ config
184
+ });
185
+ // config.cron.environments (6.0) is replaced by config.environments. Fail with the migration
186
+ // rather than run two shapes side by side.
187
+ if (!type.isUndefined(config.cron)) {
188
+ throw new ConfigError('App "config.cron" is replaced by "config.environments". Declare the deployment environments under "config.environments": give every environment its "url", move each "secret" to "cron.secret" and "enabled" to "cron.enabled", and set LOWDEFY_ENVIRONMENT on each deployment.', {
189
+ configKey: config.cron?.['~k'] ?? configKey
190
+ });
191
+ }
192
+ const environments = config.environments;
193
+ if (!type.isUndefined(environments)) {
194
+ if (!type.isObject(environments)) {
195
+ throw new ConfigError('App "config.environments" should be an object.', {
196
+ received: environments,
197
+ configKey
198
+ });
199
+ }
200
+ const envKey = environments['~k'] ?? configKey;
201
+ const names = getEnvironmentNames(environments);
202
+ if (names.length === 0) {
203
+ throw new ConfigError('App "config.environments" declares no environments.', {
204
+ configKey: envKey
205
+ });
206
+ }
207
+ names.forEach((name)=>validateEnvironment({
208
+ name,
209
+ environment: environments[name],
210
+ configKey: envKey
211
+ }));
212
+ if (!type.isUndefined(current) && !names.includes(current)) {
213
+ throw new ConfigError(`The current environment "${current}" (LOWDEFY_ENVIRONMENT) is not declared in "config.environments". Declared environments: ${names.join(', ')}.`, {
214
+ configKey: envKey
215
+ });
216
+ }
217
+ // Without a current environment every environment setting fails open: no email filter, no
218
+ // forwarded crons, no guards. That is an error in a prod build, and a warning on the dev server.
219
+ if (type.isUndefined(current)) {
220
+ context.handleWarning(new ConfigWarning(`App "config.environments" is declared but no current environment is set. Set LOWDEFY_ENVIRONMENT to the environment this deployment is (${names.join(', ')}): until then no environment setting (url, cron, email filter, guards) applies and only the "default" schedules are registered.`, {
221
+ configKey: envKey,
222
+ prodError: true
223
+ }));
224
+ }
225
+ }
226
+ // The current environment's guards are checked against this build's variables and kept for the
227
+ // production server, which checks them again at startup; other environments' guards are dropped.
228
+ if (!type.isUndefined(environments)) {
229
+ if (!type.isUndefined(current)) {
230
+ checkEnvironmentGuards({
231
+ name: current,
232
+ guards: environments[current].guards,
233
+ configKey: environments[current]['~k'] ?? configKey
234
+ });
235
+ }
236
+ getEnvironmentNames(environments).filter((name)=>name !== current).forEach((name)=>{
237
+ delete environments[name].guards;
238
+ });
239
+ }
240
+ if (type.isUndefined(current)) {
241
+ delete config.environment;
242
+ return;
243
+ }
244
+ config.environment = current;
245
+ const settings = config.environments?.[current] ?? {};
246
+ // The dev server is the local app, not the deployment the environment's url names, so the url is
247
+ // dropped: auth and notification links stay on the request origin even when a local .env names a
248
+ // deployed environment.
249
+ if (context.stage === 'dev') {
250
+ delete settings.url;
251
+ }
252
+ const disabled = switchableFeatures.filter((feature)=>settings[feature]?.enabled === false);
253
+ // App metadata is the deploy identity the server stamps on every log line and the client reads
254
+ // with _app, so the environment and the features it switches off travel with it — the browser
255
+ // has no other view of config.environments.
256
+ if (type.isObject(components.appMeta)) {
257
+ components.appMeta.environment = current;
258
+ components.appMeta.disabled = disabled;
259
+ }
260
+ }
261
+ export default buildEnvironments;
@@ -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;
@@ -157,11 +157,12 @@ async function shallowBuild(options) {
157
157
  // appMeta is computed in Phase 0; attach it here (where buildApp used to
158
158
  // create it) so the following addKeys pass keys it identically.
159
159
  components.appMeta = context.appMeta;
160
- tryBuildStep(buildLogger, 'buildLogger', {
160
+ // validateConfig resolves the current environment, which buildLogger reads for Sentry.
161
+ tryBuildStep(validateConfig, 'validateConfig', {
161
162
  components,
162
163
  context
163
164
  });
164
- tryBuildStep(validateConfig, 'validateConfig', {
165
+ tryBuildStep(buildLogger, 'buildLogger', {
165
166
  components,
166
167
  context
167
168
  });
@@ -13,8 +13,8 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { type } from '@lowdefy/helpers';
16
- import validateCronConfig from './validateCronConfig.js';
17
- function validateConfig({ components }) {
16
+ import buildEnvironments from './buildEnvironments.js';
17
+ function validateConfig({ components, context }) {
18
18
  if (type.isNone(components.config)) {
19
19
  components.config = {};
20
20
  }
@@ -26,8 +26,9 @@ function validateConfig({ components }) {
26
26
  throw new Error('Base path must start with "/".');
27
27
  }
28
28
  }
29
- validateCronConfig({
30
- components
29
+ buildEnvironments({
30
+ components,
31
+ context
31
32
  });
32
33
  return components;
33
34
  }
@@ -0,0 +1,69 @@
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 collectEnvironmentGuards from '@lowdefy/node-utils/collectEnvironmentGuards.js';
18
+ const guardKinds = [
19
+ 'secrets',
20
+ 'env'
21
+ ];
22
+ // Guard shape and patterns are validated for every environment, not only the current one, so a
23
+ // broken guard fails the build that introduces it.
24
+ function validateEnvironmentGuards({ name, guards, configKey }) {
25
+ if (type.isUndefined(guards)) return;
26
+ const where = `config.environments.${name}.guards`;
27
+ if (!type.isObject(guards)) {
28
+ throw new ConfigError(`App "${where}" should be an object.`, {
29
+ received: guards,
30
+ configKey
31
+ });
32
+ }
33
+ // An unknown key (a `secret:` typo for `secrets:`) would otherwise check nothing and let the
34
+ // value it was meant to pin through, so it fails the build.
35
+ Object.keys(guards).forEach((kind)=>{
36
+ if (kind.startsWith('~') || guardKinds.includes(kind)) return;
37
+ throw new ConfigError(`App "${where}.${kind}" is not a guard kind. Guards are "secrets" (Lowdefy secret names) and "env" (environment variable names).`, {
38
+ configKey
39
+ });
40
+ });
41
+ guardKinds.forEach((kind)=>{
42
+ const map = guards[kind];
43
+ if (type.isUndefined(map)) return;
44
+ if (!type.isObject(map)) {
45
+ throw new ConfigError(`App "${where}.${kind}" should be an object.`, {
46
+ received: map,
47
+ configKey
48
+ });
49
+ }
50
+ });
51
+ collectEnvironmentGuards({
52
+ guards
53
+ }).forEach(({ label, pattern })=>{
54
+ if (!type.isString(pattern) || pattern === '') {
55
+ throw new ConfigError(`App "${where}" ${label} should be a regular expression string.`, {
56
+ received: pattern,
57
+ configKey
58
+ });
59
+ }
60
+ try {
61
+ new RegExp(pattern);
62
+ } catch (error) {
63
+ throw new ConfigError(`App "${where}" ${label} is not a valid regular expression: ${error.message}`, {
64
+ configKey
65
+ });
66
+ }
67
+ });
68
+ }
69
+ export default validateEnvironmentGuards;