@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.cjs CHANGED
@@ -38,14 +38,18 @@ __export(index_exports, {
38
38
  SettingsLockedError: () => SettingsLockedError,
39
39
  SettingsService: () => SettingsService,
40
40
  SettingsServicePlugin: () => SettingsServicePlugin,
41
+ SettingsValidationError: () => SettingsValidationError,
41
42
  UnknownKeyError: () => UnknownKeyError,
42
43
  UnknownNamespaceError: () => UnknownNamespaceError,
44
+ VisibilityParseError: () => VisibilityParseError,
43
45
  brandingSettingsManifest: () => brandingSettingsManifest,
44
46
  builtinSettingsManifests: () => builtinSettingsManifests,
45
47
  envKeyOf: () => envKeyOf,
48
+ evaluateVisibility: () => evaluateVisibility,
46
49
  featureFlagsSettingsManifest: () => featureFlagsSettingsManifest,
47
50
  mailSettingsManifest: () => mailSettingsManifest,
48
51
  mailTestActionHandler: () => mailTestActionHandler,
52
+ referencedKeys: () => referencedKeys,
49
53
  registerSettingsRoutes: () => registerSettingsRoutes,
50
54
  settingsBuiltinTranslations: () => settingsBuiltinTranslations,
51
55
  settingsObjects: () => settingsObjects,
@@ -108,6 +112,178 @@ var UnknownKeyError = class extends Error {
108
112
  this.code = "SETTINGS_UNKNOWN_KEY";
109
113
  }
110
114
  };
115
+ var SettingsValidationError = class extends Error {
116
+ constructor(namespace, fields) {
117
+ super(
118
+ `Settings for '${namespace}' are incomplete: ` + Object.entries(fields).map(([k, msg]) => `${k} \u2014 ${msg}`).join("; ")
119
+ );
120
+ this.namespace = namespace;
121
+ this.fields = fields;
122
+ this.code = "SETTINGS_VALIDATION";
123
+ }
124
+ };
125
+
126
+ // src/visibility-eval.ts
127
+ var VisibilityParseError = class extends Error {
128
+ constructor(expr, detail) {
129
+ super(`Cannot parse visibility expression "${expr}": ${detail}`);
130
+ this.name = "VisibilityParseError";
131
+ }
132
+ };
133
+ function visibilitySource(visible) {
134
+ let src;
135
+ if (typeof visible === "string") src = visible;
136
+ else if (visible && typeof visible === "object" && typeof visible.source === "string") {
137
+ src = visible.source;
138
+ }
139
+ if (src === void 0) return void 0;
140
+ const trimmed = src.trim();
141
+ if (trimmed.startsWith("${") && trimmed.endsWith("}")) return trimmed.slice(2, -1).trim();
142
+ return trimmed;
143
+ }
144
+ function referencedKeys(visible) {
145
+ const src = visibilitySource(visible);
146
+ if (!src) return [];
147
+ return [...src.matchAll(/data\.([A-Za-z_][A-Za-z0-9_]*)/g)].map((m) => m[1]);
148
+ }
149
+ function tokenize(expr) {
150
+ const tokens = [];
151
+ let i = 0;
152
+ while (i < expr.length) {
153
+ const ch = expr[i];
154
+ if (/\s/.test(ch)) {
155
+ i++;
156
+ continue;
157
+ }
158
+ if (ch === "(" || ch === ")") {
159
+ tokens.push({ kind: "punct", value: ch });
160
+ i++;
161
+ continue;
162
+ }
163
+ let matchedOp = false;
164
+ for (const op of ["===", "!==", "==", "!=", "&&", "||"]) {
165
+ if (expr.startsWith(op, i)) {
166
+ tokens.push({ kind: "punct", value: op });
167
+ i += op.length;
168
+ matchedOp = true;
169
+ break;
170
+ }
171
+ }
172
+ if (matchedOp) continue;
173
+ if (ch === "!") {
174
+ tokens.push({ kind: "punct", value: "!" });
175
+ i++;
176
+ continue;
177
+ }
178
+ if (ch === "'" || ch === '"') {
179
+ const quote = ch;
180
+ let j = i + 1;
181
+ let out = "";
182
+ while (j < expr.length && expr[j] !== quote) {
183
+ if (expr[j] === "\\" && j + 1 < expr.length) {
184
+ out += expr[j + 1];
185
+ j += 2;
186
+ } else {
187
+ out += expr[j];
188
+ j++;
189
+ }
190
+ }
191
+ if (j >= expr.length) throw new VisibilityParseError(expr, "unterminated string");
192
+ tokens.push({ kind: "string", value: out });
193
+ i = j + 1;
194
+ continue;
195
+ }
196
+ if (/[0-9]/.test(ch)) {
197
+ const m = /^[0-9]+(\.[0-9]+)?/.exec(expr.slice(i));
198
+ tokens.push({ kind: "number", value: Number(m[0]) });
199
+ i += m[0].length;
200
+ continue;
201
+ }
202
+ if (/[A-Za-z_]/.test(ch)) {
203
+ const m = /^[A-Za-z_][A-Za-z0-9_.]*/.exec(expr.slice(i));
204
+ const word = m[0];
205
+ if (word === "true") tokens.push({ kind: "keyword", value: true });
206
+ else if (word === "false") tokens.push({ kind: "keyword", value: false });
207
+ else if (word === "null") tokens.push({ kind: "keyword", value: null });
208
+ else if (word.startsWith("data.")) {
209
+ const key = word.slice("data.".length);
210
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new VisibilityParseError(expr, `unsupported reference "${word}"`);
211
+ tokens.push({ kind: "ref", value: key });
212
+ } else throw new VisibilityParseError(expr, `unsupported identifier "${word}"`);
213
+ i += word.length;
214
+ continue;
215
+ }
216
+ throw new VisibilityParseError(expr, `unexpected character "${ch}"`);
217
+ }
218
+ return tokens;
219
+ }
220
+ function evaluateVisibility(visible, data) {
221
+ const src = visibilitySource(visible);
222
+ if (!src) return true;
223
+ const tokens = tokenize(src);
224
+ let pos = 0;
225
+ const peek = () => tokens[pos];
226
+ const eat = (value) => {
227
+ const t = tokens[pos];
228
+ if (t?.kind === "punct" && t.value === value) {
229
+ pos++;
230
+ return true;
231
+ }
232
+ return false;
233
+ };
234
+ function primary() {
235
+ const t = peek();
236
+ if (!t) throw new VisibilityParseError(src, "unexpected end of expression");
237
+ if (t.kind === "punct" && t.value === "(") {
238
+ pos++;
239
+ const v = orExpr();
240
+ if (!eat(")")) throw new VisibilityParseError(src, "missing closing parenthesis");
241
+ return v;
242
+ }
243
+ if (t.kind === "string" || t.kind === "number" || t.kind === "keyword") {
244
+ pos++;
245
+ return t.value;
246
+ }
247
+ if (t.kind === "ref") {
248
+ pos++;
249
+ return data[t.value];
250
+ }
251
+ throw new VisibilityParseError(src, `unexpected token`);
252
+ }
253
+ function comparison() {
254
+ const left = primary();
255
+ const t = peek();
256
+ if (t?.kind === "punct" && ["===", "!==", "==", "!="].includes(t.value)) {
257
+ pos++;
258
+ const right = primary();
259
+ return t.value === "===" || t.value === "==" ? left === right : left !== right;
260
+ }
261
+ return left;
262
+ }
263
+ function unary() {
264
+ if (eat("!")) return !unary();
265
+ return comparison();
266
+ }
267
+ function andExpr() {
268
+ let v = unary();
269
+ while (eat("&&")) {
270
+ const r = unary();
271
+ v = v && r;
272
+ }
273
+ return v;
274
+ }
275
+ function orExpr() {
276
+ let v = andExpr();
277
+ while (eat("||")) {
278
+ const r = andExpr();
279
+ v = v || r;
280
+ }
281
+ return v;
282
+ }
283
+ const result = orExpr();
284
+ if (pos !== tokens.length) throw new VisibilityParseError(src, "trailing tokens");
285
+ return Boolean(result);
286
+ }
111
287
 
112
288
  // src/settings-service.ts
113
289
  var DEFAULT_OBJECT = "sys_setting";
@@ -393,6 +569,7 @@ var SettingsService = class {
393
569
  throw new SettingsLockedError(namespace, key, `locked-by-${upper.scope}`);
394
570
  }
395
571
  }
572
+ await this.validatePatch(namespace, patch, ctx);
396
573
  for (const [key, rawValue] of Object.entries(patch)) {
397
574
  const scope = reg.scopes.get(key);
398
575
  const userId = scope === "user" ? ctx.userId ?? null : null;
@@ -484,12 +661,111 @@ var SettingsService = class {
484
661
  }
485
662
  return out;
486
663
  }
664
+ /**
665
+ * Save-time validation for `setMany`, fulfilling the spec promise that
666
+ * `required` is enforced server-side and hidden specifiers are not
667
+ * validated. Semantics, tuned to reject broken configs without
668
+ * breaking unrelated single-key writes:
669
+ *
670
+ * - The post-write value map is computed (current values overlaid
671
+ * with the patch; `null` patch entries fall back to the default).
672
+ * - A specifier is checked only when the patch TOUCHES it — its own
673
+ * key is in the patch, or a key its `visible` expression references
674
+ * is (switching provider must validate that provider's fields).
675
+ * - `required` + visible + empty → rejected.
676
+ * - `pattern` (text fields) + non-empty value that mismatches → rejected.
677
+ * - All-null patches (namespace reset) and unparseable visibility
678
+ * expressions skip validation rather than block the write.
679
+ */
680
+ async validatePatch(namespace, patch, ctx) {
681
+ const reg = this.registry.get(namespace);
682
+ if (!reg) return;
683
+ const entries = Object.entries(patch);
684
+ if (entries.length === 0) return;
685
+ if (entries.every(([, v]) => v === null || typeof v === "undefined")) return;
686
+ const data = {};
687
+ for (const [key] of reg.scopes) {
688
+ data[key] = (await this.get(namespace, key, ctx)).value;
689
+ }
690
+ for (const [key, value] of entries) {
691
+ data[key] = value === null || typeof value === "undefined" ? reg.defaults.get(key) ?? null : value;
692
+ }
693
+ const patchKeys = new Set(Object.keys(patch));
694
+ const errors = {};
695
+ for (const spec of reg.manifest.specifiers ?? []) {
696
+ const key = spec.key;
697
+ const type = String(spec.type ?? "");
698
+ if (!key || LAYOUT_ONLY_TYPES.has(type)) continue;
699
+ let visible = true;
700
+ let deps = [];
701
+ if (typeof spec.visible !== "undefined") {
702
+ try {
703
+ visible = evaluateVisibility(spec.visible, data);
704
+ deps = referencedKeys(spec.visible);
705
+ } catch {
706
+ continue;
707
+ }
708
+ }
709
+ if (!visible) continue;
710
+ if (!patchKeys.has(key) && !deps.some((d) => patchKeys.has(d))) continue;
711
+ const value = data[key];
712
+ const label = typeof spec.label === "string" ? spec.label : key;
713
+ const empty = value === null || typeof value === "undefined" || typeof value === "string" && value.trim() === "";
714
+ if (spec.required === true && empty) {
715
+ errors[key] = `${label} is required for this configuration.`;
716
+ continue;
717
+ }
718
+ if (!empty && typeof spec.pattern === "string" && typeof value === "string") {
719
+ let re;
720
+ try {
721
+ re = new RegExp(spec.pattern);
722
+ } catch {
723
+ re = void 0;
724
+ }
725
+ if (re && !re.test(value)) {
726
+ const hint = typeof spec.description === "string" ? ` ${spec.description}` : "";
727
+ errors[key] = `${label} does not match the expected format.${hint}`;
728
+ }
729
+ }
730
+ }
731
+ if (Object.keys(errors).length > 0) {
732
+ throw new SettingsValidationError(namespace, errors);
733
+ }
734
+ }
735
+ /**
736
+ * Clear every persisted row in a namespace so values fall back to
737
+ * env/defaults. Env-locked keys are untouched (env wins over rows
738
+ * anyway and refuses writes). Persisted rows are nulled rather than
739
+ * deleted so the audit trail records the reset per key.
740
+ *
741
+ * Returns the number of cleared keys.
742
+ */
743
+ async resetNamespace(namespace, ctx = {}) {
744
+ const payload = await this.getNamespace(namespace, ctx);
745
+ const patch = {};
746
+ for (const [key, v] of Object.entries(payload.values)) {
747
+ if (v.source === "global" || v.source === "tenant" || v.source === "user") {
748
+ patch[key] = null;
749
+ }
750
+ }
751
+ const keys = Object.keys(patch);
752
+ if (keys.length > 0) await this.setMany(namespace, patch, ctx);
753
+ return keys.length;
754
+ }
487
755
  /** Invoke a declared action (test connection, rotate, …). */
488
756
  async runAction(namespace, actionId, payload, ctx = {}) {
489
757
  const reg = this.registry.get(namespace);
490
758
  if (!reg) throw new UnknownNamespaceError(namespace);
491
759
  const handler = reg.actions.get(actionId);
492
760
  if (!handler) {
761
+ if (actionId === "reset") {
762
+ const cleared = await this.resetNamespace(namespace, ctx);
763
+ return {
764
+ ok: true,
765
+ severity: "info",
766
+ 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."
767
+ };
768
+ }
493
769
  return {
494
770
  ok: false,
495
771
  severity: "error",
@@ -916,6 +1192,11 @@ function registerSettingsRoutes(http, service, opts = {}) {
916
1192
  sendError(res, 404, "UNKNOWN_NAMESPACE", err.message);
917
1193
  } else if (err instanceof UnknownKeyError) {
918
1194
  sendError(res, 400, "UNKNOWN_KEY", err.message, { namespace: err.namespace, key: err.key });
1195
+ } else if (err instanceof SettingsValidationError) {
1196
+ sendError(res, 400, "SETTINGS_VALIDATION", err.message, {
1197
+ namespace: err.namespace,
1198
+ fields: err.fields
1199
+ });
919
1200
  } else {
920
1201
  sendError(res, 500, "INTERNAL", err?.message ?? "Failed to write namespace");
921
1202
  }
@@ -1477,7 +1758,8 @@ var manifest4 = {
1477
1758
  key: "gateway_model",
1478
1759
  label: "Gateway model",
1479
1760
  required: true,
1480
- description: "Forwarded as AI_GATEWAY_MODEL. Example: openai/gpt-4o",
1761
+ description: "Forwarded as AI_GATEWAY_MODEL. Format: provider/model, e.g. openai/gpt-4o or anthropic/claude-sonnet-4.6.",
1762
+ pattern: "^[A-Za-z0-9_-]+\\/[A-Za-z0-9._:-]+$",
1481
1763
  visible: "${data.provider === 'gateway'}"
1482
1764
  },
1483
1765
  {
@@ -1673,6 +1955,7 @@ var manifest4 = {
1673
1955
  label: "Model",
1674
1956
  required: false,
1675
1957
  default: "openai/gpt-4o-mini",
1958
+ pattern: "^[A-Za-z0-9_-]+\\/[A-Za-z0-9._:@-]+$",
1676
1959
  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.",
1677
1960
  visible: "${data.provider === 'cloudflare'}"
1678
1961
  },
@@ -1829,6 +2112,18 @@ var manifest4 = {
1829
2112
  icon: "Plug",
1830
2113
  handler: { kind: "http", method: "POST", url: "/api/settings/ai/test" }
1831
2114
  },
2115
+ // Escape hatch: clears every saved row in this namespace so the
2116
+ // runtime falls back to env auto-detection (AI_GATEWAY_MODEL /
2117
+ // OPENAI_API_KEY / …). Without it, a broken saved config can only
2118
+ // be removed by editing sys_setting by hand.
2119
+ {
2120
+ type: "action_button",
2121
+ id: "reset",
2122
+ label: "Reset to environment defaults",
2123
+ required: false,
2124
+ icon: "RotateCcw",
2125
+ handler: { kind: "http", method: "POST", url: "/api/settings/ai/reset" }
2126
+ },
1832
2127
  // ════════════════════════════════════════════════════════════════
1833
2128
  // Embedder — text → vector provider used by knowledge / RAG.
1834
2129
  // Decoupled from the chat provider above so an organisation can
@@ -3671,14 +3966,18 @@ function wrapEngineAsSettingsEngine(engine) {
3671
3966
  SettingsLockedError,
3672
3967
  SettingsService,
3673
3968
  SettingsServicePlugin,
3969
+ SettingsValidationError,
3674
3970
  UnknownKeyError,
3675
3971
  UnknownNamespaceError,
3972
+ VisibilityParseError,
3676
3973
  brandingSettingsManifest,
3677
3974
  builtinSettingsManifests,
3678
3975
  envKeyOf,
3976
+ evaluateVisibility,
3679
3977
  featureFlagsSettingsManifest,
3680
3978
  mailSettingsManifest,
3681
3979
  mailTestActionHandler,
3980
+ referencedKeys,
3682
3981
  registerSettingsRoutes,
3683
3982
  settingsBuiltinTranslations,
3684
3983
  settingsObjects,