@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.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.
|
|
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,
|