@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 +300 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +73 -1
- package/dist/index.d.ts +73 -1
- package/dist/index.js +296 -1
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
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.
|
|
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,
|