@objectstack/service-settings 9.2.0 → 9.3.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.
package/dist/index.d.cts CHANGED
@@ -231,6 +231,19 @@ declare class UnknownKeyError extends Error {
231
231
  readonly code: "SETTINGS_UNKNOWN_KEY";
232
232
  constructor(namespace: string, key: string);
233
233
  }
234
+ /**
235
+ * Thrown when a write would leave the namespace in an invalid state —
236
+ * a `required` field that is visible under the post-write values is
237
+ * empty (e.g. provider=cloudflare saved without an API key). The whole
238
+ * batch is rejected; `fields` maps each offending key to a message the
239
+ * UI can render inline.
240
+ */
241
+ declare class SettingsValidationError extends Error {
242
+ readonly namespace: string;
243
+ readonly fields: Record<string, string>;
244
+ readonly code: "SETTINGS_VALIDATION";
245
+ constructor(namespace: string, fields: Record<string, string>);
246
+ }
234
247
 
235
248
  /**
236
249
  * Concrete SettingsService. See `src/settings-service.types.ts` for
@@ -325,6 +338,32 @@ declare class SettingsService {
325
338
  set(namespace: string, key: string, value: unknown, ctx?: SettingsContext): Promise<ResolvedSettingValue>;
326
339
  /** Persist multiple keys atomically (best-effort). */
327
340
  setMany(namespace: string, patch: Record<string, unknown>, ctx?: SettingsContext): Promise<Record<string, ResolvedSettingValue>>;
341
+ /**
342
+ * Save-time validation for `setMany`, fulfilling the spec promise that
343
+ * `required` is enforced server-side and hidden specifiers are not
344
+ * validated. Semantics, tuned to reject broken configs without
345
+ * breaking unrelated single-key writes:
346
+ *
347
+ * - The post-write value map is computed (current values overlaid
348
+ * with the patch; `null` patch entries fall back to the default).
349
+ * - A specifier is checked only when the patch TOUCHES it — its own
350
+ * key is in the patch, or a key its `visible` expression references
351
+ * is (switching provider must validate that provider's fields).
352
+ * - `required` + visible + empty → rejected.
353
+ * - `pattern` (text fields) + non-empty value that mismatches → rejected.
354
+ * - All-null patches (namespace reset) and unparseable visibility
355
+ * expressions skip validation rather than block the write.
356
+ */
357
+ private validatePatch;
358
+ /**
359
+ * Clear every persisted row in a namespace so values fall back to
360
+ * env/defaults. Env-locked keys are untouched (env wins over rows
361
+ * anyway and refuses writes). Persisted rows are nulled rather than
362
+ * deleted so the audit trail records the reset per key.
363
+ *
364
+ * Returns the number of cleared keys.
365
+ */
366
+ resetNamespace(namespace: string, ctx?: SettingsContext): Promise<number>;
328
367
  /** Invoke a declared action (test connection, rotate, …). */
329
368
  runAction(namespace: string, actionId: string, payload: unknown, ctx?: SettingsContext): Promise<SettingsActionResult>;
330
369
  private loadRows;
@@ -376,6 +415,39 @@ declare class LocalCryptoProvider implements ICryptoProvider {
376
415
  */
377
416
  declare const InMemoryCryptoProvider: typeof LocalCryptoProvider;
378
417
 
418
+ /**
419
+ * Evaluator for the restricted visibility expressions used by settings
420
+ * manifests, e.g. `"${data.provider === 'cloudflare'}"` or
421
+ * `"${data.embedder_provider && data.embedder_provider !== 'none'}"`.
422
+ *
423
+ * The server needs these at save time so `setMany` can enforce `required`
424
+ * only on fields that are actually visible for the current provider —
425
+ * a half-filled Cloudflare form must be rejected, while OpenAI fields
426
+ * stay irrelevant. The console UI evaluates the same strings client-side;
427
+ * this is deliberately NOT a general JS evaluator, just the tiny grammar
428
+ * the manifests use:
429
+ *
430
+ * orExpr := andExpr ('||' andExpr)*
431
+ * andExpr := unary ('&&' unary)*
432
+ * unary := '!' unary | comparison
433
+ * compare := primary (('===' | '!==' | '==' | '!=') primary)?
434
+ * primary := '(' orExpr ')' | string | number | true | false | null | data.<ident>
435
+ *
436
+ * Anything outside the grammar throws `VisibilityParseError`; callers
437
+ * should treat that as "cannot determine visibility" and skip validation
438
+ * for the field (lenient) rather than block the save.
439
+ */
440
+ declare class VisibilityParseError extends Error {
441
+ constructor(expr: string, detail: string);
442
+ }
443
+ /** `data.*` keys referenced by a visibility expression (regex-level scan). */
444
+ declare function referencedKeys(visible: unknown): string[];
445
+ /**
446
+ * Evaluate a visibility expression against the merged form data.
447
+ * Throws {@link VisibilityParseError} for anything outside the grammar.
448
+ */
449
+ declare function evaluateVisibility(visible: unknown, data: Record<string, unknown>): boolean;
450
+
379
451
  /** Configuration options for the SettingsServicePlugin. */
380
452
  interface SettingsServicePluginOptions {
381
453
  /**
@@ -644,4 +716,4 @@ declare const jaJP: TranslationData;
644
716
 
645
717
  declare const settingsBuiltinTranslations: TranslationBundle;
646
718
 
647
- export { type CryptoAdapter, type CryptoMode, InMemoryCryptoProvider, type KeySource, LocalCryptoProvider, type LocalCryptoProviderOptions, NoopCryptoAdapter, SETTINGS_PLUGIN_ID, SETTINGS_PLUGIN_VERSION, type SettingsActionHandler, type SettingsAuditSink, type SettingsContext, type SettingsEngine, SettingsLockedError, type SettingsRoutesOptions, type SettingsRow, SettingsService, type SettingsServiceOptions, SettingsServicePlugin, type SettingsServicePluginOptions, UnknownKeyError, UnknownNamespaceError, brandingSettingsManifest, builtinSettingsManifests, envKeyOf, featureFlagsSettingsManifest, mailSettingsManifest, mailTestActionHandler, registerSettingsRoutes, settingsBuiltinTranslations, settingsObjects, settingsPluginManifestHeader, en as settingsTranslationsEn, jaJP as settingsTranslationsJaJP, zhCN as settingsTranslationsZhCN, storageSettingsManifest, storageTestActionHandler };
719
+ export { type CryptoAdapter, type CryptoMode, InMemoryCryptoProvider, type KeySource, LocalCryptoProvider, type LocalCryptoProviderOptions, NoopCryptoAdapter, SETTINGS_PLUGIN_ID, SETTINGS_PLUGIN_VERSION, type SettingsActionHandler, type SettingsAuditSink, type SettingsContext, type SettingsEngine, SettingsLockedError, type SettingsRoutesOptions, type SettingsRow, SettingsService, type SettingsServiceOptions, SettingsServicePlugin, type SettingsServicePluginOptions, SettingsValidationError, UnknownKeyError, UnknownNamespaceError, VisibilityParseError, brandingSettingsManifest, builtinSettingsManifests, envKeyOf, evaluateVisibility, featureFlagsSettingsManifest, mailSettingsManifest, mailTestActionHandler, referencedKeys, registerSettingsRoutes, settingsBuiltinTranslations, settingsObjects, settingsPluginManifestHeader, en as settingsTranslationsEn, jaJP as settingsTranslationsJaJP, zhCN as settingsTranslationsZhCN, storageSettingsManifest, storageTestActionHandler };
package/dist/index.d.ts CHANGED
@@ -231,6 +231,19 @@ declare class UnknownKeyError extends Error {
231
231
  readonly code: "SETTINGS_UNKNOWN_KEY";
232
232
  constructor(namespace: string, key: string);
233
233
  }
234
+ /**
235
+ * Thrown when a write would leave the namespace in an invalid state —
236
+ * a `required` field that is visible under the post-write values is
237
+ * empty (e.g. provider=cloudflare saved without an API key). The whole
238
+ * batch is rejected; `fields` maps each offending key to a message the
239
+ * UI can render inline.
240
+ */
241
+ declare class SettingsValidationError extends Error {
242
+ readonly namespace: string;
243
+ readonly fields: Record<string, string>;
244
+ readonly code: "SETTINGS_VALIDATION";
245
+ constructor(namespace: string, fields: Record<string, string>);
246
+ }
234
247
 
235
248
  /**
236
249
  * Concrete SettingsService. See `src/settings-service.types.ts` for
@@ -325,6 +338,32 @@ declare class SettingsService {
325
338
  set(namespace: string, key: string, value: unknown, ctx?: SettingsContext): Promise<ResolvedSettingValue>;
326
339
  /** Persist multiple keys atomically (best-effort). */
327
340
  setMany(namespace: string, patch: Record<string, unknown>, ctx?: SettingsContext): Promise<Record<string, ResolvedSettingValue>>;
341
+ /**
342
+ * Save-time validation for `setMany`, fulfilling the spec promise that
343
+ * `required` is enforced server-side and hidden specifiers are not
344
+ * validated. Semantics, tuned to reject broken configs without
345
+ * breaking unrelated single-key writes:
346
+ *
347
+ * - The post-write value map is computed (current values overlaid
348
+ * with the patch; `null` patch entries fall back to the default).
349
+ * - A specifier is checked only when the patch TOUCHES it — its own
350
+ * key is in the patch, or a key its `visible` expression references
351
+ * is (switching provider must validate that provider's fields).
352
+ * - `required` + visible + empty → rejected.
353
+ * - `pattern` (text fields) + non-empty value that mismatches → rejected.
354
+ * - All-null patches (namespace reset) and unparseable visibility
355
+ * expressions skip validation rather than block the write.
356
+ */
357
+ private validatePatch;
358
+ /**
359
+ * Clear every persisted row in a namespace so values fall back to
360
+ * env/defaults. Env-locked keys are untouched (env wins over rows
361
+ * anyway and refuses writes). Persisted rows are nulled rather than
362
+ * deleted so the audit trail records the reset per key.
363
+ *
364
+ * Returns the number of cleared keys.
365
+ */
366
+ resetNamespace(namespace: string, ctx?: SettingsContext): Promise<number>;
328
367
  /** Invoke a declared action (test connection, rotate, …). */
329
368
  runAction(namespace: string, actionId: string, payload: unknown, ctx?: SettingsContext): Promise<SettingsActionResult>;
330
369
  private loadRows;
@@ -376,6 +415,39 @@ declare class LocalCryptoProvider implements ICryptoProvider {
376
415
  */
377
416
  declare const InMemoryCryptoProvider: typeof LocalCryptoProvider;
378
417
 
418
+ /**
419
+ * Evaluator for the restricted visibility expressions used by settings
420
+ * manifests, e.g. `"${data.provider === 'cloudflare'}"` or
421
+ * `"${data.embedder_provider && data.embedder_provider !== 'none'}"`.
422
+ *
423
+ * The server needs these at save time so `setMany` can enforce `required`
424
+ * only on fields that are actually visible for the current provider —
425
+ * a half-filled Cloudflare form must be rejected, while OpenAI fields
426
+ * stay irrelevant. The console UI evaluates the same strings client-side;
427
+ * this is deliberately NOT a general JS evaluator, just the tiny grammar
428
+ * the manifests use:
429
+ *
430
+ * orExpr := andExpr ('||' andExpr)*
431
+ * andExpr := unary ('&&' unary)*
432
+ * unary := '!' unary | comparison
433
+ * compare := primary (('===' | '!==' | '==' | '!=') primary)?
434
+ * primary := '(' orExpr ')' | string | number | true | false | null | data.<ident>
435
+ *
436
+ * Anything outside the grammar throws `VisibilityParseError`; callers
437
+ * should treat that as "cannot determine visibility" and skip validation
438
+ * for the field (lenient) rather than block the save.
439
+ */
440
+ declare class VisibilityParseError extends Error {
441
+ constructor(expr: string, detail: string);
442
+ }
443
+ /** `data.*` keys referenced by a visibility expression (regex-level scan). */
444
+ declare function referencedKeys(visible: unknown): string[];
445
+ /**
446
+ * Evaluate a visibility expression against the merged form data.
447
+ * Throws {@link VisibilityParseError} for anything outside the grammar.
448
+ */
449
+ declare function evaluateVisibility(visible: unknown, data: Record<string, unknown>): boolean;
450
+
379
451
  /** Configuration options for the SettingsServicePlugin. */
380
452
  interface SettingsServicePluginOptions {
381
453
  /**
@@ -644,4 +716,4 @@ declare const jaJP: TranslationData;
644
716
 
645
717
  declare const settingsBuiltinTranslations: TranslationBundle;
646
718
 
647
- export { type CryptoAdapter, type CryptoMode, InMemoryCryptoProvider, type KeySource, LocalCryptoProvider, type LocalCryptoProviderOptions, NoopCryptoAdapter, SETTINGS_PLUGIN_ID, SETTINGS_PLUGIN_VERSION, type SettingsActionHandler, type SettingsAuditSink, type SettingsContext, type SettingsEngine, SettingsLockedError, type SettingsRoutesOptions, type SettingsRow, SettingsService, type SettingsServiceOptions, SettingsServicePlugin, type SettingsServicePluginOptions, UnknownKeyError, UnknownNamespaceError, brandingSettingsManifest, builtinSettingsManifests, envKeyOf, featureFlagsSettingsManifest, mailSettingsManifest, mailTestActionHandler, registerSettingsRoutes, settingsBuiltinTranslations, settingsObjects, settingsPluginManifestHeader, en as settingsTranslationsEn, jaJP as settingsTranslationsJaJP, zhCN as settingsTranslationsZhCN, storageSettingsManifest, storageTestActionHandler };
719
+ export { type CryptoAdapter, type CryptoMode, InMemoryCryptoProvider, type KeySource, LocalCryptoProvider, type LocalCryptoProviderOptions, NoopCryptoAdapter, SETTINGS_PLUGIN_ID, SETTINGS_PLUGIN_VERSION, type SettingsActionHandler, type SettingsAuditSink, type SettingsContext, type SettingsEngine, SettingsLockedError, type SettingsRoutesOptions, type SettingsRow, SettingsService, type SettingsServiceOptions, SettingsServicePlugin, type SettingsServicePluginOptions, SettingsValidationError, UnknownKeyError, UnknownNamespaceError, VisibilityParseError, brandingSettingsManifest, builtinSettingsManifests, envKeyOf, evaluateVisibility, featureFlagsSettingsManifest, mailSettingsManifest, mailTestActionHandler, referencedKeys, registerSettingsRoutes, settingsBuiltinTranslations, settingsObjects, settingsPluginManifestHeader, en as settingsTranslationsEn, jaJP as settingsTranslationsJaJP, zhCN as settingsTranslationsZhCN, storageSettingsManifest, storageTestActionHandler };
package/dist/index.js CHANGED
@@ -48,6 +48,178 @@ var UnknownKeyError = class extends Error {
48
48
  this.code = "SETTINGS_UNKNOWN_KEY";
49
49
  }
50
50
  };
51
+ var SettingsValidationError = class extends Error {
52
+ constructor(namespace, fields) {
53
+ super(
54
+ `Settings for '${namespace}' are incomplete: ` + Object.entries(fields).map(([k, msg]) => `${k} \u2014 ${msg}`).join("; ")
55
+ );
56
+ this.namespace = namespace;
57
+ this.fields = fields;
58
+ this.code = "SETTINGS_VALIDATION";
59
+ }
60
+ };
61
+
62
+ // src/visibility-eval.ts
63
+ var VisibilityParseError = class extends Error {
64
+ constructor(expr, detail) {
65
+ super(`Cannot parse visibility expression "${expr}": ${detail}`);
66
+ this.name = "VisibilityParseError";
67
+ }
68
+ };
69
+ function visibilitySource(visible) {
70
+ let src;
71
+ if (typeof visible === "string") src = visible;
72
+ else if (visible && typeof visible === "object" && typeof visible.source === "string") {
73
+ src = visible.source;
74
+ }
75
+ if (src === void 0) return void 0;
76
+ const trimmed = src.trim();
77
+ if (trimmed.startsWith("${") && trimmed.endsWith("}")) return trimmed.slice(2, -1).trim();
78
+ return trimmed;
79
+ }
80
+ function referencedKeys(visible) {
81
+ const src = visibilitySource(visible);
82
+ if (!src) return [];
83
+ return [...src.matchAll(/data\.([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1]);
84
+ }
85
+ function tokenize(expr) {
86
+ const tokens = [];
87
+ let i = 0;
88
+ while (i < expr.length) {
89
+ const ch = expr[i];
90
+ if (/\s/.test(ch)) {
91
+ i++;
92
+ continue;
93
+ }
94
+ if (ch === "(" || ch === ")") {
95
+ tokens.push({ kind: "punct", value: ch });
96
+ i++;
97
+ continue;
98
+ }
99
+ let matchedOp = false;
100
+ for (const op of ["===", "!==", "==", "!=", "&&", "||"]) {
101
+ if (expr.startsWith(op, i)) {
102
+ tokens.push({ kind: "punct", value: op });
103
+ i += op.length;
104
+ matchedOp = true;
105
+ break;
106
+ }
107
+ }
108
+ if (matchedOp) continue;
109
+ if (ch === "!") {
110
+ tokens.push({ kind: "punct", value: "!" });
111
+ i++;
112
+ continue;
113
+ }
114
+ if (ch === "'" || ch === '"') {
115
+ const quote = ch;
116
+ let j = i + 1;
117
+ let out = "";
118
+ while (j < expr.length && expr[j] !== quote) {
119
+ if (expr[j] === "\\" && j + 1 < expr.length) {
120
+ out += expr[j + 1];
121
+ j += 2;
122
+ } else {
123
+ out += expr[j];
124
+ j++;
125
+ }
126
+ }
127
+ if (j >= expr.length) throw new VisibilityParseError(expr, "unterminated string");
128
+ tokens.push({ kind: "string", value: out });
129
+ i = j + 1;
130
+ continue;
131
+ }
132
+ if (/[0-9]/.test(ch)) {
133
+ const m = /^[0-9]+(\.[0-9]+)?/.exec(expr.slice(i));
134
+ tokens.push({ kind: "number", value: Number(m[0]) });
135
+ i += m[0].length;
136
+ continue;
137
+ }
138
+ if (/[A-Za-z_]/.test(ch)) {
139
+ const m = /^[A-Za-z_][A-Za-z0-9_.]*/.exec(expr.slice(i));
140
+ const word = m[0];
141
+ if (word === "true") tokens.push({ kind: "keyword", value: true });
142
+ else if (word === "false") tokens.push({ kind: "keyword", value: false });
143
+ else if (word === "null") tokens.push({ kind: "keyword", value: null });
144
+ else if (word.startsWith("data.")) {
145
+ const key = word.slice("data.".length);
146
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new VisibilityParseError(expr, `unsupported reference "${word}"`);
147
+ tokens.push({ kind: "ref", value: key });
148
+ } else throw new VisibilityParseError(expr, `unsupported identifier "${word}"`);
149
+ i += word.length;
150
+ continue;
151
+ }
152
+ throw new VisibilityParseError(expr, `unexpected character "${ch}"`);
153
+ }
154
+ return tokens;
155
+ }
156
+ function evaluateVisibility(visible, data) {
157
+ const src = visibilitySource(visible);
158
+ if (!src) return true;
159
+ const tokens = tokenize(src);
160
+ let pos = 0;
161
+ const peek = () => tokens[pos];
162
+ const eat = (value) => {
163
+ const t = tokens[pos];
164
+ if (t?.kind === "punct" && t.value === value) {
165
+ pos++;
166
+ return true;
167
+ }
168
+ return false;
169
+ };
170
+ function primary() {
171
+ const t = peek();
172
+ if (!t) throw new VisibilityParseError(src, "unexpected end of expression");
173
+ if (t.kind === "punct" && t.value === "(") {
174
+ pos++;
175
+ const v = orExpr();
176
+ if (!eat(")")) throw new VisibilityParseError(src, "missing closing parenthesis");
177
+ return v;
178
+ }
179
+ if (t.kind === "string" || t.kind === "number" || t.kind === "keyword") {
180
+ pos++;
181
+ return t.value;
182
+ }
183
+ if (t.kind === "ref") {
184
+ pos++;
185
+ return data[t.value];
186
+ }
187
+ throw new VisibilityParseError(src, `unexpected token`);
188
+ }
189
+ function comparison() {
190
+ const left = primary();
191
+ const t = peek();
192
+ if (t?.kind === "punct" && ["===", "!==", "==", "!="].includes(t.value)) {
193
+ pos++;
194
+ const right = primary();
195
+ return t.value === "===" || t.value === "==" ? left === right : left !== right;
196
+ }
197
+ return left;
198
+ }
199
+ function unary() {
200
+ if (eat("!")) return !unary();
201
+ return comparison();
202
+ }
203
+ function andExpr() {
204
+ let v = unary();
205
+ while (eat("&&")) {
206
+ const r = unary();
207
+ v = v && r;
208
+ }
209
+ return v;
210
+ }
211
+ function orExpr() {
212
+ let v = andExpr();
213
+ while (eat("||")) {
214
+ const r = andExpr();
215
+ v = v || r;
216
+ }
217
+ return v;
218
+ }
219
+ const result = orExpr();
220
+ if (pos !== tokens.length) throw new VisibilityParseError(src, "trailing tokens");
221
+ return Boolean(result);
222
+ }
51
223
 
52
224
  // src/settings-service.ts
53
225
  var DEFAULT_OBJECT = "sys_setting";
@@ -333,6 +505,7 @@ var SettingsService = class {
333
505
  throw new SettingsLockedError(namespace, key, `locked-by-${upper.scope}`);
334
506
  }
335
507
  }
508
+ await this.validatePatch(namespace, patch, ctx);
336
509
  for (const [key, rawValue] of Object.entries(patch)) {
337
510
  const scope = reg.scopes.get(key);
338
511
  const userId = scope === "user" ? ctx.userId ?? null : null;
@@ -424,12 +597,111 @@ var SettingsService = class {
424
597
  }
425
598
  return out;
426
599
  }
600
+ /**
601
+ * Save-time validation for `setMany`, fulfilling the spec promise that
602
+ * `required` is enforced server-side and hidden specifiers are not
603
+ * validated. Semantics, tuned to reject broken configs without
604
+ * breaking unrelated single-key writes:
605
+ *
606
+ * - The post-write value map is computed (current values overlaid
607
+ * with the patch; `null` patch entries fall back to the default).
608
+ * - A specifier is checked only when the patch TOUCHES it — its own
609
+ * key is in the patch, or a key its `visible` expression references
610
+ * is (switching provider must validate that provider's fields).
611
+ * - `required` + visible + empty → rejected.
612
+ * - `pattern` (text fields) + non-empty value that mismatches → rejected.
613
+ * - All-null patches (namespace reset) and unparseable visibility
614
+ * expressions skip validation rather than block the write.
615
+ */
616
+ async validatePatch(namespace, patch, ctx) {
617
+ const reg = this.registry.get(namespace);
618
+ if (!reg) return;
619
+ const entries = Object.entries(patch);
620
+ if (entries.length === 0) return;
621
+ if (entries.every(([, v]) => v === null || typeof v === "undefined")) return;
622
+ const data = {};
623
+ for (const [key] of reg.scopes) {
624
+ data[key] = (await this.get(namespace, key, ctx)).value;
625
+ }
626
+ for (const [key, value] of entries) {
627
+ data[key] = value === null || typeof value === "undefined" ? reg.defaults.get(key) ?? null : value;
628
+ }
629
+ const patchKeys = new Set(Object.keys(patch));
630
+ const errors = {};
631
+ for (const spec of reg.manifest.specifiers ?? []) {
632
+ const key = spec.key;
633
+ const type = String(spec.type ?? "");
634
+ if (!key || LAYOUT_ONLY_TYPES.has(type)) continue;
635
+ let visible = true;
636
+ let deps = [];
637
+ if (typeof spec.visible !== "undefined") {
638
+ try {
639
+ visible = evaluateVisibility(spec.visible, data);
640
+ deps = referencedKeys(spec.visible);
641
+ } catch {
642
+ continue;
643
+ }
644
+ }
645
+ if (!visible) continue;
646
+ if (!patchKeys.has(key) && !deps.some((d) => patchKeys.has(d))) continue;
647
+ const value = data[key];
648
+ const label = typeof spec.label === "string" ? spec.label : key;
649
+ const empty = value === null || typeof value === "undefined" || typeof value === "string" && value.trim() === "";
650
+ if (spec.required === true && empty) {
651
+ errors[key] = `${label} is required for this configuration.`;
652
+ continue;
653
+ }
654
+ if (!empty && typeof spec.pattern === "string" && typeof value === "string") {
655
+ let re;
656
+ try {
657
+ re = new RegExp(spec.pattern);
658
+ } catch {
659
+ re = void 0;
660
+ }
661
+ if (re && !re.test(value)) {
662
+ const hint = typeof spec.description === "string" ? ` ${spec.description}` : "";
663
+ errors[key] = `${label} does not match the expected format.${hint}`;
664
+ }
665
+ }
666
+ }
667
+ if (Object.keys(errors).length > 0) {
668
+ throw new SettingsValidationError(namespace, errors);
669
+ }
670
+ }
671
+ /**
672
+ * Clear every persisted row in a namespace so values fall back to
673
+ * env/defaults. Env-locked keys are untouched (env wins over rows
674
+ * anyway and refuses writes). Persisted rows are nulled rather than
675
+ * deleted so the audit trail records the reset per key.
676
+ *
677
+ * Returns the number of cleared keys.
678
+ */
679
+ async resetNamespace(namespace, ctx = {}) {
680
+ const payload = await this.getNamespace(namespace, ctx);
681
+ const patch = {};
682
+ for (const [key, v] of Object.entries(payload.values)) {
683
+ if (v.source === "global" || v.source === "tenant" || v.source === "user") {
684
+ patch[key] = null;
685
+ }
686
+ }
687
+ const keys = Object.keys(patch);
688
+ if (keys.length > 0) await this.setMany(namespace, patch, ctx);
689
+ return keys.length;
690
+ }
427
691
  /** Invoke a declared action (test connection, rotate, …). */
428
692
  async runAction(namespace, actionId, payload, ctx = {}) {
429
693
  const reg = this.registry.get(namespace);
430
694
  if (!reg) throw new UnknownNamespaceError(namespace);
431
695
  const handler = reg.actions.get(actionId);
432
696
  if (!handler) {
697
+ if (actionId === "reset") {
698
+ const cleared = await this.resetNamespace(namespace, ctx);
699
+ return {
700
+ ok: true,
701
+ severity: "info",
702
+ message: cleared > 0 ? `Cleared ${cleared} saved value(s); environment/default configuration is back in effect.` : "No saved values to clear \u2014 already using environment/default configuration."
703
+ };
704
+ }
433
705
  return {
434
706
  ok: false,
435
707
  severity: "error",
@@ -856,6 +1128,11 @@ function registerSettingsRoutes(http, service, opts = {}) {
856
1128
  sendError(res, 404, "UNKNOWN_NAMESPACE", err.message);
857
1129
  } else if (err instanceof UnknownKeyError) {
858
1130
  sendError(res, 400, "UNKNOWN_KEY", err.message, { namespace: err.namespace, key: err.key });
1131
+ } else if (err instanceof SettingsValidationError) {
1132
+ sendError(res, 400, "SETTINGS_VALIDATION", err.message, {
1133
+ namespace: err.namespace,
1134
+ fields: err.fields
1135
+ });
859
1136
  } else {
860
1137
  sendError(res, 500, "INTERNAL", err?.message ?? "Failed to write namespace");
861
1138
  }
@@ -1417,7 +1694,8 @@ var manifest4 = {
1417
1694
  key: "gateway_model",
1418
1695
  label: "Gateway model",
1419
1696
  required: true,
1420
- description: "Forwarded as AI_GATEWAY_MODEL. Example: openai/gpt-4o",
1697
+ description: "Forwarded as AI_GATEWAY_MODEL. Format: provider/model, e.g. openai/gpt-4o or anthropic/claude-sonnet-4.6.",
1698
+ pattern: "^[A-Za-z0-9_-]+\\/[A-Za-z0-9._:-]+$",
1421
1699
  visible: "${data.provider === 'gateway'}"
1422
1700
  },
1423
1701
  {
@@ -1613,6 +1891,7 @@ var manifest4 = {
1613
1891
  label: "Model",
1614
1892
  required: false,
1615
1893
  default: "openai/gpt-4o-mini",
1894
+ pattern: "^[A-Za-z0-9_-]+\\/[A-Za-z0-9._:@-]+$",
1616
1895
  description: "Format: provider/model. Allowed providers (per Cloudflare /compat): anthropic, openai, groq, mistral, cohere, perplexity, workers-ai, google-ai-studio, vertex, grok, deepseek, cerebras, baseten, parallel.",
1617
1896
  visible: "${data.provider === 'cloudflare'}"
1618
1897
  },
@@ -1769,6 +2048,18 @@ var manifest4 = {
1769
2048
  icon: "Plug",
1770
2049
  handler: { kind: "http", method: "POST", url: "/api/settings/ai/test" }
1771
2050
  },
2051
+ // Escape hatch: clears every saved row in this namespace so the
2052
+ // runtime falls back to env auto-detection (AI_GATEWAY_MODEL /
2053
+ // OPENAI_API_KEY / …). Without it, a broken saved config can only
2054
+ // be removed by editing sys_setting by hand.
2055
+ {
2056
+ type: "action_button",
2057
+ id: "reset",
2058
+ label: "Reset to environment defaults",
2059
+ required: false,
2060
+ icon: "RotateCcw",
2061
+ handler: { kind: "http", method: "POST", url: "/api/settings/ai/reset" }
2062
+ },
1772
2063
  // ════════════════════════════════════════════════════════════════
1773
2064
  // Embedder — text → vector provider used by knowledge / RAG.
1774
2065
  // Decoupled from the chat provider above so an organisation can
@@ -3610,14 +3901,18 @@ export {
3610
3901
  SettingsLockedError,
3611
3902
  SettingsService,
3612
3903
  SettingsServicePlugin,
3904
+ SettingsValidationError,
3613
3905
  UnknownKeyError,
3614
3906
  UnknownNamespaceError,
3907
+ VisibilityParseError,
3615
3908
  brandingSettingsManifest,
3616
3909
  builtinSettingsManifests,
3617
3910
  envKeyOf,
3911
+ evaluateVisibility,
3618
3912
  featureFlagsSettingsManifest,
3619
3913
  mailSettingsManifest,
3620
3914
  mailTestActionHandler,
3915
+ referencedKeys,
3621
3916
  registerSettingsRoutes,
3622
3917
  settingsBuiltinTranslations,
3623
3918
  settingsObjects,