@objectstack/service-settings 0.1.1

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.js ADDED
@@ -0,0 +1,1697 @@
1
+ // src/crypto-adapter.ts
2
+ var NoopCryptoAdapter = class {
3
+ async encrypt(plaintext) {
4
+ return "b64:" + Buffer.from(plaintext, "utf8").toString("base64");
5
+ }
6
+ async decrypt(ciphertext) {
7
+ if (!ciphertext.startsWith("b64:")) {
8
+ return ciphertext;
9
+ }
10
+ return Buffer.from(ciphertext.slice(4), "base64").toString("utf8");
11
+ }
12
+ digest(plaintext) {
13
+ let h = 2166136261;
14
+ for (let i = 0; i < plaintext.length; i++) {
15
+ h ^= plaintext.charCodeAt(i);
16
+ h = h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
17
+ }
18
+ return "fnv32:" + h.toString(16).padStart(8, "0");
19
+ }
20
+ };
21
+
22
+ // src/settings-service.types.ts
23
+ function envKeyOf(namespace, key) {
24
+ const slug = `${namespace}_${key}`.replace(/[.-]/g, "_").toUpperCase();
25
+ return slug;
26
+ }
27
+ var SettingsLockedError = class extends Error {
28
+ constructor(namespace, key, reason = "locked-by-env") {
29
+ super(`Setting '${namespace}.${key}' is locked (${reason}).`);
30
+ this.namespace = namespace;
31
+ this.key = key;
32
+ this.reason = reason;
33
+ this.code = "SETTINGS_LOCKED";
34
+ }
35
+ };
36
+ var UnknownNamespaceError = class extends Error {
37
+ constructor(namespace) {
38
+ super(`No settings manifest registered for namespace '${namespace}'.`);
39
+ this.namespace = namespace;
40
+ this.code = "SETTINGS_UNKNOWN_NAMESPACE";
41
+ }
42
+ };
43
+ var UnknownKeyError = class extends Error {
44
+ constructor(namespace, key) {
45
+ super(`Key '${key}' is not declared in manifest '${namespace}'.`);
46
+ this.namespace = namespace;
47
+ this.key = key;
48
+ this.code = "SETTINGS_UNKNOWN_KEY";
49
+ }
50
+ };
51
+
52
+ // src/settings-service.ts
53
+ var DEFAULT_OBJECT = "sys_setting";
54
+ var LAYOUT_ONLY_TYPES = /* @__PURE__ */ new Set([
55
+ "group",
56
+ "info_banner",
57
+ "child_pane",
58
+ "title_value",
59
+ "action_button"
60
+ ]);
61
+ var SettingsService = class {
62
+ constructor(opts = {}) {
63
+ this.registry = /* @__PURE__ */ new Map();
64
+ /** In-memory fallback when no engine is wired. */
65
+ this.memory = [];
66
+ /** Change subscribers, optionally scoped to a namespace. */
67
+ this.subscribers = /* @__PURE__ */ new Set();
68
+ this.engine = opts.engine;
69
+ this.crypto = opts.crypto ?? new NoopCryptoAdapter();
70
+ this.cryptoProvider = opts.cryptoProvider;
71
+ this.secretStore = opts.secretStore;
72
+ this.audit = opts.audit;
73
+ this.auditWriter = opts.auditWriter;
74
+ this.env = opts.env ?? (typeof process !== "undefined" ? process.env : {});
75
+ this.objectName = opts.objectName ?? DEFAULT_OBJECT;
76
+ }
77
+ /**
78
+ * Late-bind a data engine and (optionally) an audit sink. Plugins
79
+ * call this from `kernel:ready` once `objectql` is wired so the
80
+ * SettingsService swaps from its in-memory fallback to the real
81
+ * `sys_setting` table without re-registering the service.
82
+ */
83
+ bindEngine(engine, audit, extras) {
84
+ this.engine = engine;
85
+ if (audit) this.audit = audit;
86
+ if (extras?.secretStore) this.secretStore = extras.secretStore;
87
+ if (extras?.auditWriter) this.auditWriter = extras.auditWriter;
88
+ if (extras?.cryptoProvider) this.cryptoProvider = extras.cryptoProvider;
89
+ }
90
+ /**
91
+ * Cascade priority ranks for lock comparisons (lower = higher
92
+ * precedence). env<global<tenant<user<default. A locked row at a
93
+ * lower rank blocks writes at all higher ranks.
94
+ */
95
+ scopeRank(scope) {
96
+ switch (scope) {
97
+ case "global":
98
+ return 1;
99
+ case "tenant":
100
+ return 2;
101
+ case "user":
102
+ return 3;
103
+ default:
104
+ return 99;
105
+ }
106
+ }
107
+ // ---------------------------------------------------------------------
108
+ // Change events (Phase 1)
109
+ // ---------------------------------------------------------------------
110
+ /**
111
+ * Subscribe to `settings:changed` events. When `namespace` is set the
112
+ * handler only fires for that namespace, otherwise it fires for every
113
+ * mutation across the service.
114
+ *
115
+ * Returns an idempotent unsubscribe handle — call it from the
116
+ * consumer's shutdown hook to avoid leaks.
117
+ */
118
+ subscribe(namespace, handler) {
119
+ const entry = { ns: namespace, handler };
120
+ this.subscribers.add(entry);
121
+ return () => {
122
+ this.subscribers.delete(entry);
123
+ };
124
+ }
125
+ /**
126
+ * Dispatch a change event to all matching subscribers. Errors thrown
127
+ * by a handler are swallowed to keep the bus crash-safe — handlers
128
+ * are expected to enqueue async work themselves.
129
+ */
130
+ emitChange(event) {
131
+ if (this.subscribers.size === 0) return;
132
+ for (const sub of this.subscribers) {
133
+ if (sub.ns && sub.ns !== event.namespace) continue;
134
+ try {
135
+ sub.handler(event);
136
+ } catch {
137
+ }
138
+ }
139
+ }
140
+ // ---------------------------------------------------------------------
141
+ // Manifest registry
142
+ // ---------------------------------------------------------------------
143
+ /** Register (or replace) a manifest. Idempotent. */
144
+ registerManifest(manifest3) {
145
+ const scopes = /* @__PURE__ */ new Map();
146
+ const encryptedKeys = /* @__PURE__ */ new Set();
147
+ const defaults = /* @__PURE__ */ new Map();
148
+ const defaultScope = manifest3.scope ?? "tenant";
149
+ for (const spec of manifest3.specifiers) {
150
+ if (!spec.key || LAYOUT_ONLY_TYPES.has(spec.type)) continue;
151
+ scopes.set(spec.key, spec.scope ?? defaultScope);
152
+ if (spec.encrypted || spec.type === "password") encryptedKeys.add(spec.key);
153
+ if (typeof spec.default !== "undefined") defaults.set(spec.key, spec.default);
154
+ }
155
+ const prev = this.registry.get(manifest3.namespace);
156
+ const actions = prev?.actions ?? /* @__PURE__ */ new Map();
157
+ this.registry.set(manifest3.namespace, { manifest: manifest3, scopes, encryptedKeys, defaults, actions });
158
+ }
159
+ /** Look up a manifest, or throw `UnknownNamespaceError`. */
160
+ getManifest(namespace) {
161
+ const reg = this.registry.get(namespace);
162
+ if (!reg) throw new UnknownNamespaceError(namespace);
163
+ return reg.manifest;
164
+ }
165
+ /** List all registered manifests, optionally filtered by permission. */
166
+ listManifests(ctx = {}) {
167
+ const perms = new Set(ctx.permissions ?? []);
168
+ const all = Array.from(this.registry.values()).map((r) => r.manifest);
169
+ if (perms.size === 0) return all;
170
+ return all.filter((m) => perms.has(m.readPermission ?? "setup.access"));
171
+ }
172
+ /** Register a handler for an `action_button` declared in a manifest. */
173
+ registerAction(namespace, actionId, handler) {
174
+ const reg = this.registry.get(namespace);
175
+ if (!reg) throw new UnknownNamespaceError(namespace);
176
+ reg.actions.set(actionId, handler);
177
+ }
178
+ // ---------------------------------------------------------------------
179
+ // Resolver
180
+ // ---------------------------------------------------------------------
181
+ /** Resolve a single key. */
182
+ async get(namespace, key, ctx = {}) {
183
+ const reg = this.registry.get(namespace);
184
+ if (!reg) throw new UnknownNamespaceError(namespace);
185
+ if (!reg.scopes.has(key)) throw new UnknownKeyError(namespace, key);
186
+ const envName = envKeyOf(namespace, key);
187
+ const envRaw = this.env[envName];
188
+ if (typeof envRaw === "string") {
189
+ const def2 = reg.defaults.get(key);
190
+ const value = coerceEnvValue(envRaw, def2);
191
+ return {
192
+ value,
193
+ source: "env",
194
+ locked: true,
195
+ lockedReason: `Set via env: ${envName}`,
196
+ cascadeChain: [
197
+ { scope: "env", value, locked: true, lockedReason: `Set via env: ${envName}`, effective: true }
198
+ ]
199
+ };
200
+ }
201
+ const scope = reg.scopes.get(key);
202
+ const rows = await this.loadRows(namespace, scope === "user" ? ctx.userId ?? null : null);
203
+ const chain = [];
204
+ const globalRow = rows.find((r) => r.key === key && r.scope === "global");
205
+ if (globalRow) {
206
+ const value = await this.materialiseRow(globalRow);
207
+ chain.push({
208
+ scope: "global",
209
+ value,
210
+ locked: !!globalRow.locked,
211
+ lockedReason: globalRow.locked_reason ?? void 0
212
+ });
213
+ }
214
+ if (scope === "tenant" || scope === "user") {
215
+ const tenantRow = rows.find((r) => r.key === key && r.scope === "tenant");
216
+ if (tenantRow) {
217
+ chain.push({
218
+ scope: "tenant",
219
+ value: await this.materialiseRow(tenantRow),
220
+ locked: !!tenantRow.locked,
221
+ lockedReason: tenantRow.locked_reason ?? void 0
222
+ });
223
+ }
224
+ }
225
+ if (scope === "user") {
226
+ const userRow = rows.find((r) => r.key === key && r.scope === "user");
227
+ if (userRow) {
228
+ chain.push({
229
+ scope: "user",
230
+ value: await this.materialiseRow(userRow)
231
+ });
232
+ }
233
+ }
234
+ const def = reg.defaults.get(key);
235
+ chain.push({ scope: "default", value: def ?? null });
236
+ const lockedEntry = chain.find((e) => e.locked === true);
237
+ const effective = chain.find((e) => e.value !== null && e.value !== void 0) ?? chain[chain.length - 1];
238
+ effective.effective = true;
239
+ return {
240
+ value: effective.value,
241
+ source: effective.scope,
242
+ locked: !!lockedEntry,
243
+ lockedReason: lockedEntry?.lockedReason,
244
+ cascadeChain: chain
245
+ };
246
+ }
247
+ /** Resolve every value in a namespace + return the manifest. */
248
+ async getNamespace(namespace, ctx = {}) {
249
+ const reg = this.registry.get(namespace);
250
+ if (!reg) throw new UnknownNamespaceError(namespace);
251
+ const values = {};
252
+ for (const [key] of reg.scopes) {
253
+ values[key] = await this.get(namespace, key, ctx);
254
+ }
255
+ return { manifest: reg.manifest, values };
256
+ }
257
+ // ---------------------------------------------------------------------
258
+ // Reactive client (Phase 1)
259
+ // ---------------------------------------------------------------------
260
+ /**
261
+ * Build a reactive `ISettingsClient` for a namespace.
262
+ *
263
+ * The client maintains an internal snapshot of the resolved values,
264
+ * refreshing on every `settings:changed` event for the namespace.
265
+ * Consumers call `current` / `get(key)` for synchronous reads and
266
+ * register handlers via `onChange()`.
267
+ *
268
+ * `schema` is optional. When supplied, the snapshot is parsed (and
269
+ * defaulted) through the Zod schema on each refresh — this gives
270
+ * plugins strong types and runtime validation in one call. When
271
+ * absent, raw resolved values flow through unchanged (used by the
272
+ * dynamic console UI which validates per-field).
273
+ */
274
+ async createClient(namespace, opts = {}) {
275
+ const ctx = opts.ctx ?? {};
276
+ let snapshot = await this.snapshotOf(namespace, ctx, opts.parse);
277
+ const off = this.subscribe(namespace, () => {
278
+ void this.snapshotOf(namespace, ctx, opts.parse).then((next) => {
279
+ snapshot = next;
280
+ });
281
+ });
282
+ return {
283
+ namespace,
284
+ get current() {
285
+ return snapshot;
286
+ },
287
+ get(key) {
288
+ return snapshot[key];
289
+ },
290
+ onChange: (handler) => this.subscribe(namespace, handler),
291
+ refresh: async () => {
292
+ snapshot = await this.snapshotOf(namespace, ctx, opts.parse);
293
+ },
294
+ dispose: off
295
+ };
296
+ }
297
+ async snapshotOf(namespace, ctx, parse) {
298
+ const payload = await this.getNamespace(namespace, ctx);
299
+ const raw = {};
300
+ for (const [k, v] of Object.entries(payload.values)) raw[k] = v.value;
301
+ return parse ? parse(raw) : raw;
302
+ }
303
+ // ---------------------------------------------------------------------
304
+ // Mutations
305
+ // ---------------------------------------------------------------------
306
+ /** Persist a single key. Throws SettingsLockedError when env-locked. */
307
+ async set(namespace, key, value, ctx = {}) {
308
+ return (await this.setMany(namespace, { [key]: value }, ctx))[key];
309
+ }
310
+ /** Persist multiple keys atomically (best-effort). */
311
+ async setMany(namespace, patch, ctx = {}) {
312
+ const reg = this.registry.get(namespace);
313
+ if (!reg) throw new UnknownNamespaceError(namespace);
314
+ for (const key of Object.keys(patch)) {
315
+ if (!reg.scopes.has(key)) throw new UnknownKeyError(namespace, key);
316
+ const envRaw = this.env[envKeyOf(namespace, key)];
317
+ if (typeof envRaw === "string") throw new SettingsLockedError(namespace, key);
318
+ const scope = reg.scopes.get(key);
319
+ const rows = await this.loadRows(namespace, scope === "user" ? ctx.userId ?? null : null);
320
+ const upper = rows.find(
321
+ (r) => r.key === key && r.locked === true && this.scopeRank(r.scope) < this.scopeRank(scope)
322
+ );
323
+ if (upper) {
324
+ throw new SettingsLockedError(namespace, key, `locked-by-${upper.scope}`);
325
+ }
326
+ }
327
+ for (const [key, rawValue] of Object.entries(patch)) {
328
+ const scope = reg.scopes.get(key);
329
+ const userId = scope === "user" ? ctx.userId ?? null : null;
330
+ const isEncrypted = reg.encryptedKeys.has(key);
331
+ const isNull = rawValue === null || typeof rawValue === "undefined";
332
+ let storedValue = null;
333
+ let storedEnc = null;
334
+ let digest = "";
335
+ if (!isNull) {
336
+ if (isEncrypted) {
337
+ const plain = typeof rawValue === "string" ? rawValue : JSON.stringify(rawValue);
338
+ if (this.cryptoProvider && this.secretStore) {
339
+ const handle = await this.cryptoProvider.encrypt(plain, {
340
+ namespace,
341
+ key,
342
+ tenantId: ctx.tenantId
343
+ });
344
+ await this.secretStore.insert({
345
+ id: handle.id,
346
+ namespace,
347
+ key,
348
+ kms_key_id: handle.kmsKeyId,
349
+ alg: handle.alg,
350
+ version: handle.version,
351
+ ciphertext: handle.ciphertext
352
+ });
353
+ storedEnc = handle.id;
354
+ digest = this.cryptoProvider.digest(plain);
355
+ } else {
356
+ storedEnc = await this.crypto.encrypt(plain, { namespace, key });
357
+ digest = this.crypto.digest(plain);
358
+ }
359
+ } else {
360
+ storedValue = rawValue;
361
+ digest = this.crypto.digest(stableStringify(rawValue));
362
+ }
363
+ }
364
+ await this.upsertRow({
365
+ namespace,
366
+ key,
367
+ scope,
368
+ user_id: userId,
369
+ value: storedValue,
370
+ value_enc: storedEnc,
371
+ encrypted: isEncrypted,
372
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
373
+ updated_by: ctx.userId ?? null
374
+ });
375
+ if (this.audit) {
376
+ await this.audit.record({
377
+ namespace,
378
+ key,
379
+ scope,
380
+ userId: ctx.userId,
381
+ action: isNull ? "reset" : "set",
382
+ valueDigest: isEncrypted ? "<encrypted:" + digest + ">" : digest,
383
+ encrypted: isEncrypted,
384
+ requestId: ctx.requestId
385
+ });
386
+ }
387
+ if (this.auditWriter) {
388
+ try {
389
+ await this.auditWriter.write({
390
+ namespace,
391
+ key,
392
+ scope,
393
+ action: isNull ? "reset" : "set",
394
+ source: "api",
395
+ actorId: ctx.userId,
396
+ oldHash: null,
397
+ newHash: isNull ? null : digest,
398
+ encrypted: isEncrypted,
399
+ requestId: ctx.requestId
400
+ });
401
+ } catch {
402
+ }
403
+ }
404
+ this.emitChange({
405
+ namespace,
406
+ key,
407
+ scope,
408
+ action: isNull ? "reset" : "set",
409
+ at: (/* @__PURE__ */ new Date()).toISOString()
410
+ });
411
+ }
412
+ const out = {};
413
+ for (const key of Object.keys(patch)) {
414
+ out[key] = await this.get(namespace, key, ctx);
415
+ }
416
+ return out;
417
+ }
418
+ /** Invoke a declared action (test connection, rotate, …). */
419
+ async runAction(namespace, actionId, payload, ctx = {}) {
420
+ const reg = this.registry.get(namespace);
421
+ if (!reg) throw new UnknownNamespaceError(namespace);
422
+ const handler = reg.actions.get(actionId);
423
+ if (!handler) {
424
+ return {
425
+ ok: false,
426
+ severity: "error",
427
+ message: `No handler registered for action '${actionId}' in '${namespace}'.`
428
+ };
429
+ }
430
+ const values = {};
431
+ for (const [key] of reg.scopes) {
432
+ values[key] = (await this.get(namespace, key, ctx)).value;
433
+ }
434
+ try {
435
+ return await handler({ namespace, actionId, values, payload, ctx });
436
+ } catch (err) {
437
+ return {
438
+ ok: false,
439
+ severity: "error",
440
+ message: err?.message ?? "Action handler threw."
441
+ };
442
+ }
443
+ }
444
+ // ---------------------------------------------------------------------
445
+ // Persistence helpers (engine or in-memory)
446
+ // ---------------------------------------------------------------------
447
+ async loadRows(namespace, userId) {
448
+ if (this.engine) {
449
+ const where = { namespace };
450
+ if (userId !== null) where.user_id = userId;
451
+ const rows = await this.engine.find(this.objectName, {
452
+ where,
453
+ bypassTenantAudit: true
454
+ });
455
+ return rows.map((r) => ({
456
+ namespace: r.namespace,
457
+ key: r.key,
458
+ scope: r.scope,
459
+ user_id: r.user_id ?? null,
460
+ value: r.value ?? null,
461
+ value_enc: r.value_enc ?? null,
462
+ encrypted: Boolean(r.encrypted),
463
+ locked: Boolean(r.locked),
464
+ locked_reason: r.locked_reason ?? null,
465
+ updated_at: r.updated_at,
466
+ updated_by: r.updated_by ?? null
467
+ }));
468
+ }
469
+ return this.memory.filter(
470
+ (r) => r.namespace === namespace && (userId === null || r.user_id === userId || r.scope === "tenant" || r.scope === "global")
471
+ );
472
+ }
473
+ async upsertRow(row) {
474
+ if (this.engine) {
475
+ const where = {
476
+ namespace: row.namespace,
477
+ key: row.key,
478
+ scope: row.scope,
479
+ user_id: row.user_id ?? null
480
+ };
481
+ const bypass = row.scope === "global" ? { bypassTenantAudit: true } : {};
482
+ const existing = await this.engine.find(this.objectName, {
483
+ where,
484
+ limit: 1,
485
+ ...bypass
486
+ });
487
+ if (existing[0]) {
488
+ await this.engine.update(this.objectName, {
489
+ where,
490
+ data: { ...row },
491
+ ...bypass
492
+ });
493
+ } else {
494
+ await this.engine.insert(this.objectName, { ...row }, bypass);
495
+ }
496
+ return;
497
+ }
498
+ const idx = this.memory.findIndex(
499
+ (r) => r.namespace === row.namespace && r.key === row.key && r.scope === row.scope && (r.user_id ?? null) === (row.user_id ?? null)
500
+ );
501
+ if (idx >= 0) this.memory[idx] = row;
502
+ else this.memory.push(row);
503
+ }
504
+ async materialiseRow(row) {
505
+ if (row.encrypted) {
506
+ if (!row.value_enc) return null;
507
+ let plain;
508
+ if (this.cryptoProvider && this.secretStore && typeof row.value_enc === "string" && row.value_enc.startsWith("sec_")) {
509
+ const secret = await this.secretStore.get(row.value_enc);
510
+ if (!secret) return null;
511
+ plain = await this.cryptoProvider.decrypt(
512
+ {
513
+ id: secret.id,
514
+ kmsKeyId: secret.kms_key_id,
515
+ alg: secret.alg,
516
+ version: secret.version,
517
+ ciphertext: secret.ciphertext
518
+ },
519
+ { namespace: row.namespace, key: row.key }
520
+ );
521
+ } else {
522
+ plain = await this.crypto.decrypt(row.value_enc, {
523
+ namespace: row.namespace,
524
+ key: row.key
525
+ });
526
+ }
527
+ try {
528
+ return JSON.parse(plain);
529
+ } catch {
530
+ return plain;
531
+ }
532
+ }
533
+ return row.value ?? null;
534
+ }
535
+ };
536
+ function stableStringify(input) {
537
+ if (input === null || typeof input !== "object") return JSON.stringify(input);
538
+ if (Array.isArray(input)) return "[" + input.map(stableStringify).join(",") + "]";
539
+ const obj = input;
540
+ const keys = Object.keys(obj).sort();
541
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",") + "}";
542
+ }
543
+ function coerceEnvValue(raw, hint) {
544
+ if (typeof hint === "boolean") return raw === "true" || raw === "1" || raw === "yes";
545
+ if (typeof hint === "number") {
546
+ const n = Number(raw);
547
+ return Number.isFinite(n) ? n : raw;
548
+ }
549
+ if (Array.isArray(hint) || hint && typeof hint === "object") {
550
+ try {
551
+ return JSON.parse(raw);
552
+ } catch {
553
+ return raw;
554
+ }
555
+ }
556
+ return raw;
557
+ }
558
+
559
+ // src/in-memory-crypto-provider.ts
560
+ import { createHash, randomBytes, createCipheriv, createDecipheriv } from "crypto";
561
+ var InMemoryCryptoProvider = class {
562
+ constructor(opts = {}) {
563
+ this.key = opts.key ?? randomBytes(32);
564
+ }
565
+ async encrypt(plain, ctx) {
566
+ const iv = randomBytes(12);
567
+ const cipher = createCipheriv("aes-256-gcm", this.key, iv);
568
+ cipher.setAAD(Buffer.from(this.aadOf(ctx), "utf8"));
569
+ const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
570
+ const tag = cipher.getAuthTag();
571
+ const blob = Buffer.concat([iv, tag, enc]).toString("base64");
572
+ return {
573
+ id: "sec_" + randomBytes(16).toString("hex"),
574
+ kmsKeyId: "local:in-memory:v1",
575
+ alg: "aes-256-gcm",
576
+ version: 1,
577
+ ciphertext: blob
578
+ };
579
+ }
580
+ async decrypt(handle, ctx) {
581
+ const buf = Buffer.from(handle.ciphertext, "base64");
582
+ const iv = buf.subarray(0, 12);
583
+ const tag = buf.subarray(12, 28);
584
+ const data = buf.subarray(28);
585
+ const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
586
+ decipher.setAAD(Buffer.from(this.aadOf(ctx), "utf8"));
587
+ decipher.setAuthTag(tag);
588
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf8");
589
+ }
590
+ async rotateKey(handle, ctx) {
591
+ const plain = await this.decrypt(handle, ctx);
592
+ const next = await this.encrypt(plain, ctx);
593
+ return {
594
+ ...next,
595
+ id: handle.id,
596
+ kmsKeyId: `local:in-memory:v${handle.version + 1}`,
597
+ version: handle.version + 1
598
+ };
599
+ }
600
+ digest(plain) {
601
+ return "sha256:" + createHash("sha256").update(plain, "utf8").digest("hex");
602
+ }
603
+ aadOf(ctx) {
604
+ return [ctx.namespace, ctx.key].join("|");
605
+ }
606
+ };
607
+
608
+ // src/settings-routes.ts
609
+ var defaultContext = (req) => {
610
+ const header = (name) => {
611
+ const v = req.headers?.[name];
612
+ return Array.isArray(v) ? v[0] : v;
613
+ };
614
+ const perms = header("x-permissions");
615
+ return {
616
+ userId: header("x-user-id"),
617
+ tenantId: header("x-tenant-id"),
618
+ permissions: perms ? perms.split(",").map((s) => s.trim()).filter(Boolean) : void 0,
619
+ requestId: header("x-request-id")
620
+ };
621
+ };
622
+ function sendError(res, status, code, message, extra) {
623
+ res.status(status).json({ error: { code, message, ...extra } });
624
+ }
625
+ function registerSettingsRoutes(http, service, opts = {}) {
626
+ const base = opts.basePath ?? "/api/settings";
627
+ const ctxOf = opts.contextFromRequest ?? defaultContext;
628
+ http.get(base, (async (req, res) => {
629
+ try {
630
+ const ctx = ctxOf(req);
631
+ const manifests = service.listManifests(ctx);
632
+ await res.json({ manifests });
633
+ } catch (err) {
634
+ sendError(res, 500, "INTERNAL", err?.message ?? "Failed to list manifests");
635
+ }
636
+ }));
637
+ http.get(`${base}/:namespace`, (async (req, res) => {
638
+ const ns = req.params.namespace;
639
+ try {
640
+ const ctx = ctxOf(req);
641
+ const payload = await service.getNamespace(ns, ctx);
642
+ await res.json(payload);
643
+ } catch (err) {
644
+ if (err instanceof UnknownNamespaceError) {
645
+ sendError(res, 404, "UNKNOWN_NAMESPACE", err.message);
646
+ } else {
647
+ sendError(res, 500, "INTERNAL", err?.message ?? "Failed to read namespace");
648
+ }
649
+ }
650
+ }));
651
+ http.put(`${base}/:namespace`, (async (req, res) => {
652
+ const ns = req.params.namespace;
653
+ const body = req.body ?? {};
654
+ try {
655
+ const ctx = ctxOf(req);
656
+ const result = await service.setMany(ns, body, ctx);
657
+ await res.json({ values: result });
658
+ } catch (err) {
659
+ if (err instanceof SettingsLockedError) {
660
+ sendError(res, 409, "SETTINGS_LOCKED", err.message, {
661
+ namespace: err.namespace,
662
+ key: err.key,
663
+ reason: err.reason
664
+ });
665
+ } else if (err instanceof UnknownNamespaceError) {
666
+ sendError(res, 404, "UNKNOWN_NAMESPACE", err.message);
667
+ } else if (err instanceof UnknownKeyError) {
668
+ sendError(res, 400, "UNKNOWN_KEY", err.message, { namespace: err.namespace, key: err.key });
669
+ } else {
670
+ sendError(res, 500, "INTERNAL", err?.message ?? "Failed to write namespace");
671
+ }
672
+ }
673
+ }));
674
+ http.post(`${base}/:namespace/:actionId`, (async (req, res) => {
675
+ const { namespace, actionId } = req.params;
676
+ try {
677
+ const ctx = ctxOf(req);
678
+ const result = await service.runAction(namespace, actionId, req.body, ctx);
679
+ const status = result.ok ? 200 : 400;
680
+ await res.status(status).json(result);
681
+ } catch (err) {
682
+ if (err instanceof UnknownNamespaceError) {
683
+ sendError(res, 404, "UNKNOWN_NAMESPACE", err.message);
684
+ } else {
685
+ sendError(res, 500, "INTERNAL", err?.message ?? "Action failed");
686
+ }
687
+ }
688
+ }));
689
+ }
690
+
691
+ // src/manifest.ts
692
+ import { SysSetting, SysSecret, SysSettingAudit } from "@objectstack/platform-objects/system";
693
+ var SETTINGS_PLUGIN_ID = "com.objectstack.service.settings";
694
+ var SETTINGS_PLUGIN_VERSION = "0.1.0";
695
+ var settingsObjects = [SysSetting, SysSecret, SysSettingAudit];
696
+ var settingsPluginManifestHeader = {
697
+ id: SETTINGS_PLUGIN_ID,
698
+ namespace: "sys",
699
+ version: SETTINGS_PLUGIN_VERSION,
700
+ type: "plugin",
701
+ scope: "project",
702
+ name: "Settings Service",
703
+ description: "Generic settings registry + K/V resolver with Env > Tenant > User > Default precedence. ADR-0007."
704
+ };
705
+
706
+ // src/manifests/mail.manifest.ts
707
+ var manifest = {
708
+ namespace: "mail",
709
+ version: 1,
710
+ label: "Mail Delivery",
711
+ icon: "Mail",
712
+ description: "SMTP and transactional email provider configuration.",
713
+ scope: "global",
714
+ readPermission: "setup.access",
715
+ writePermission: "setup.write",
716
+ category: "Communication",
717
+ order: 10,
718
+ specifiers: [
719
+ {
720
+ type: "group",
721
+ id: "provider",
722
+ label: "Provider",
723
+ required: false,
724
+ description: "Choose how this workspace sends outbound email."
725
+ },
726
+ {
727
+ type: "select",
728
+ key: "provider",
729
+ label: "Provider",
730
+ required: true,
731
+ default: "smtp",
732
+ options: [
733
+ { value: "smtp", label: "SMTP" },
734
+ { value: "sendgrid", label: "SendGrid" },
735
+ { value: "ses", label: "Amazon SES" },
736
+ { value: "postmark", label: "Postmark" }
737
+ ]
738
+ },
739
+ { type: "group", id: "smtp", label: "SMTP", required: false, visible: "${data.provider === 'smtp'}" },
740
+ {
741
+ type: "text",
742
+ key: "smtp_host",
743
+ label: "Host",
744
+ required: true,
745
+ description: "Example: smtp.example.com",
746
+ visible: "${data.provider === 'smtp'}"
747
+ },
748
+ {
749
+ type: "number",
750
+ key: "smtp_port",
751
+ label: "Port",
752
+ required: false,
753
+ default: 587,
754
+ min: 1,
755
+ max: 65535,
756
+ visible: "${data.provider === 'smtp'}"
757
+ },
758
+ {
759
+ type: "toggle",
760
+ key: "smtp_secure",
761
+ label: "Use TLS",
762
+ required: false,
763
+ default: true,
764
+ visible: "${data.provider === 'smtp'}"
765
+ },
766
+ {
767
+ type: "text",
768
+ key: "smtp_user",
769
+ label: "Username",
770
+ required: false,
771
+ visible: "${data.provider === 'smtp'}"
772
+ },
773
+ {
774
+ type: "password",
775
+ key: "smtp_password",
776
+ label: "Password",
777
+ required: false,
778
+ visible: "${data.provider === 'smtp'}"
779
+ },
780
+ { type: "group", id: "api_key", label: "API key", required: false, visible: "${data.provider !== 'smtp'}" },
781
+ {
782
+ type: "password",
783
+ key: "api_key",
784
+ label: "API key",
785
+ required: true,
786
+ encrypted: true,
787
+ visible: "${data.provider !== 'smtp'}"
788
+ },
789
+ { type: "group", id: "from_address", label: "From address", required: false },
790
+ {
791
+ type: "email",
792
+ key: "from_email",
793
+ label: "From email",
794
+ required: true,
795
+ description: "Example: no-reply@example.com"
796
+ },
797
+ { type: "text", key: "from_name", label: "From name", required: false, default: "ObjectStack" },
798
+ {
799
+ type: "action_button",
800
+ id: "test",
801
+ label: "Send test email",
802
+ required: false,
803
+ icon: "Send",
804
+ handler: { kind: "http", method: "POST", url: "/api/settings/mail/test" }
805
+ }
806
+ ]
807
+ };
808
+ var mailSettingsManifest = manifest;
809
+ var mailTestActionHandler = async ({ values }) => {
810
+ const provider = String(values.provider ?? "smtp");
811
+ const fromEmail = values.from_email;
812
+ if (!fromEmail) {
813
+ return { ok: false, severity: "error", message: "Configure a from address before testing." };
814
+ }
815
+ if (provider === "smtp" && !values.smtp_host) {
816
+ return { ok: false, severity: "error", message: "SMTP host is required." };
817
+ }
818
+ if (provider !== "smtp" && !values.api_key) {
819
+ return { ok: false, severity: "error", message: "API key is required." };
820
+ }
821
+ return {
822
+ ok: true,
823
+ severity: "info",
824
+ message: `Configuration looks valid (provider=${provider}). Wire @objectstack/plugin-mail for actual delivery.`
825
+ };
826
+ };
827
+
828
+ // src/manifests/branding.manifest.ts
829
+ var brandingSettingsManifest = {
830
+ namespace: "branding",
831
+ version: 1,
832
+ label: "Branding",
833
+ icon: "Palette",
834
+ description: "Workspace name, logo, and accent colour.",
835
+ scope: "tenant",
836
+ readPermission: "setup.access",
837
+ writePermission: "setup.write",
838
+ category: "Workspace",
839
+ order: 5,
840
+ specifiers: [
841
+ { type: "group", id: "identity", label: "Identity", required: false },
842
+ {
843
+ type: "text",
844
+ key: "workspace_name",
845
+ label: "Workspace name",
846
+ required: true,
847
+ default: "ObjectStack",
848
+ minLength: 1,
849
+ maxLength: 60
850
+ },
851
+ {
852
+ type: "email",
853
+ key: "support_email",
854
+ label: "Support email",
855
+ required: false,
856
+ description: "Example: support@example.com"
857
+ },
858
+ { type: "group", id: "appearance", label: "Appearance", required: false },
859
+ {
860
+ type: "select",
861
+ key: "theme_mode",
862
+ label: "Default theme",
863
+ required: false,
864
+ default: "system",
865
+ options: [
866
+ { value: "light", label: "Light" },
867
+ { value: "dark", label: "Dark" },
868
+ { value: "system", label: "Match system" }
869
+ ]
870
+ },
871
+ { type: "color", key: "accent_color", label: "Accent colour", required: false, default: "#6366f1" },
872
+ {
873
+ type: "url",
874
+ key: "logo_url",
875
+ label: "Logo URL",
876
+ required: false,
877
+ description: "Example: https://\u2026/logo.svg"
878
+ }
879
+ ]
880
+ };
881
+
882
+ // src/manifests/feature-flags.manifest.ts
883
+ var featureFlagsSettingsManifest = {
884
+ namespace: "feature_flags",
885
+ version: 1,
886
+ label: "Feature Flags",
887
+ icon: "FlaskConical",
888
+ description: "Toggle experimental and beta features for this workspace.",
889
+ scope: "tenant",
890
+ readPermission: "setup.access",
891
+ writePermission: "setup.write",
892
+ category: "Beta",
893
+ order: 100,
894
+ beta: true,
895
+ specifiers: [
896
+ {
897
+ type: "info_banner",
898
+ id: "beta_notice",
899
+ label: "Heads up",
900
+ required: false,
901
+ bannerText: "Beta features may change without notice. Pin via env vars (e.g. `FEATURE_FLAGS_AI_ENABLED=true`) to lock for the whole deployment.",
902
+ bannerSeverity: "warning"
903
+ },
904
+ { type: "group", id: "productivity", label: "Productivity", required: false },
905
+ {
906
+ type: "toggle",
907
+ key: "ai_enabled",
908
+ label: "AI Assistant",
909
+ required: false,
910
+ default: false,
911
+ description: "Enables the in-app AI assistant panel."
912
+ },
913
+ { type: "toggle", key: "kanban_swimlanes", label: "Kanban swimlanes", required: false, default: false },
914
+ { type: "group", id: "collaboration", label: "Collaboration", required: false },
915
+ { type: "toggle", key: "realtime_cursors", label: "Realtime cursors", required: false, default: false },
916
+ { type: "toggle", key: "inline_comments", label: "Inline comments", required: false, default: true }
917
+ ]
918
+ };
919
+
920
+ // src/manifests/storage.manifest.ts
921
+ var manifest2 = {
922
+ namespace: "storage",
923
+ version: 1,
924
+ label: "File Storage",
925
+ icon: "HardDrive",
926
+ description: "Backend used for attachments, exports, and user uploads. \u26A0 Switching adapter does not migrate existing files \u2014 files uploaded under the previous adapter become unreachable through the new one.",
927
+ scope: "global",
928
+ readPermission: "setup.access",
929
+ writePermission: "setup.write",
930
+ category: "Infrastructure",
931
+ order: 20,
932
+ specifiers: [
933
+ {
934
+ type: "group",
935
+ id: "adapter",
936
+ label: "Backend",
937
+ required: false,
938
+ description: "Choose where uploaded files are stored."
939
+ },
940
+ {
941
+ type: "select",
942
+ key: "adapter",
943
+ label: "Adapter",
944
+ required: true,
945
+ default: "local",
946
+ options: [
947
+ { value: "local", label: "Local filesystem" },
948
+ { value: "s3", label: "S3 / S3-compatible" }
949
+ ]
950
+ },
951
+ {
952
+ type: "group",
953
+ id: "local",
954
+ label: "Local",
955
+ required: false,
956
+ visible: "${data.adapter === 'local'}"
957
+ },
958
+ {
959
+ type: "text",
960
+ key: "local_root",
961
+ label: "Root directory",
962
+ required: false,
963
+ default: "./.objectstack/data/uploads",
964
+ description: "Filesystem path under which files are stored. Relative paths resolve from the server CWD.",
965
+ visible: "${data.adapter === 'local'}"
966
+ },
967
+ {
968
+ type: "group",
969
+ id: "s3",
970
+ label: "S3",
971
+ required: false,
972
+ visible: "${data.adapter === 's3'}"
973
+ },
974
+ {
975
+ type: "text",
976
+ key: "s3_bucket",
977
+ label: "Bucket",
978
+ required: true,
979
+ description: "Shared host bucket. Per-project files are namespaced via the projects/<projectId>/ prefix.",
980
+ visible: "${data.adapter === 's3'}"
981
+ },
982
+ {
983
+ type: "text",
984
+ key: "s3_region",
985
+ label: "Region",
986
+ required: true,
987
+ description: "Example: us-east-1",
988
+ visible: "${data.adapter === 's3'}"
989
+ },
990
+ {
991
+ type: "text",
992
+ key: "s3_endpoint",
993
+ label: "Endpoint",
994
+ required: false,
995
+ description: "Custom endpoint for S3-compatible providers (R2, MinIO, Wasabi). Leave blank for AWS S3.",
996
+ visible: "${data.adapter === 's3'}"
997
+ },
998
+ {
999
+ type: "text",
1000
+ key: "s3_access_key_id",
1001
+ label: "Access key ID",
1002
+ required: true,
1003
+ visible: "${data.adapter === 's3'}"
1004
+ },
1005
+ {
1006
+ type: "password",
1007
+ key: "s3_secret_access_key",
1008
+ label: "Secret access key",
1009
+ required: true,
1010
+ encrypted: true,
1011
+ visible: "${data.adapter === 's3'}"
1012
+ },
1013
+ {
1014
+ type: "toggle",
1015
+ key: "s3_force_path_style",
1016
+ label: "Force path-style URLs",
1017
+ required: false,
1018
+ default: false,
1019
+ description: "Enable for MinIO and most S3-compatible providers; disable for AWS S3.",
1020
+ visible: "${data.adapter === 's3'}"
1021
+ },
1022
+ { type: "group", id: "limits", label: "Limits", required: false },
1023
+ {
1024
+ type: "number",
1025
+ key: "presigned_ttl",
1026
+ label: "Presigned URL TTL (seconds)",
1027
+ required: false,
1028
+ default: 3600,
1029
+ min: 60,
1030
+ max: 604800
1031
+ },
1032
+ {
1033
+ type: "number",
1034
+ key: "session_ttl",
1035
+ label: "Upload session TTL (seconds)",
1036
+ required: false,
1037
+ default: 86400,
1038
+ min: 300,
1039
+ max: 604800,
1040
+ description: "How long a chunked-upload session stays resumable."
1041
+ },
1042
+ {
1043
+ type: "number",
1044
+ key: "max_upload_mb",
1045
+ label: "Max upload size (MB)",
1046
+ required: false,
1047
+ default: 100,
1048
+ min: 1,
1049
+ max: 10240
1050
+ },
1051
+ {
1052
+ type: "action_button",
1053
+ id: "test",
1054
+ label: "Test connection",
1055
+ required: false,
1056
+ icon: "Plug",
1057
+ handler: { kind: "http", method: "POST", url: "/api/settings/storage/test" }
1058
+ }
1059
+ ]
1060
+ };
1061
+ var storageSettingsManifest = manifest2;
1062
+ var storageTestActionHandler = async ({ values }) => {
1063
+ const adapter = String(values.adapter ?? "local");
1064
+ if (adapter === "local") {
1065
+ const root = values.local_root;
1066
+ if (!root) {
1067
+ return { ok: false, severity: "error", message: "Configure a root directory before testing." };
1068
+ }
1069
+ return {
1070
+ ok: true,
1071
+ severity: "info",
1072
+ message: `Local adapter configured (root=${root}). Mount @objectstack/service-storage to exercise live I/O.`
1073
+ };
1074
+ }
1075
+ if (adapter === "s3") {
1076
+ const missing = [];
1077
+ if (!values.s3_bucket) missing.push("s3_bucket");
1078
+ if (!values.s3_region) missing.push("s3_region");
1079
+ if (!values.s3_access_key_id) missing.push("s3_access_key_id");
1080
+ if (!values.s3_secret_access_key) missing.push("s3_secret_access_key");
1081
+ if (missing.length) {
1082
+ return { ok: false, severity: "error", message: `Missing required field${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}` };
1083
+ }
1084
+ return {
1085
+ ok: true,
1086
+ severity: "info",
1087
+ message: `S3 adapter configured (bucket=${values.s3_bucket}, region=${values.s3_region}). Mount @objectstack/service-storage to exercise live I/O.`
1088
+ };
1089
+ }
1090
+ return { ok: false, severity: "error", message: `Unknown adapter: ${adapter}` };
1091
+ };
1092
+
1093
+ // src/manifests/index.ts
1094
+ var builtinSettingsManifests = [
1095
+ brandingSettingsManifest,
1096
+ mailSettingsManifest,
1097
+ storageSettingsManifest,
1098
+ featureFlagsSettingsManifest
1099
+ ];
1100
+
1101
+ // src/translations/en.ts
1102
+ var en = {
1103
+ settingsCommon: {
1104
+ sourceLabels: {
1105
+ env: "Env",
1106
+ global: "Global",
1107
+ tenant: "Tenant",
1108
+ user: "User",
1109
+ default: "Default"
1110
+ }
1111
+ },
1112
+ settings: {
1113
+ mail: {
1114
+ title: "Mail Delivery",
1115
+ description: "SMTP and transactional email provider configuration.",
1116
+ groups: {
1117
+ provider: { title: "Provider", description: "Choose how this workspace sends outbound email." },
1118
+ smtp: { title: "SMTP" },
1119
+ api_key: { title: "API key" },
1120
+ from_address: { title: "From address" }
1121
+ },
1122
+ keys: {
1123
+ provider: {
1124
+ label: "Provider",
1125
+ options: {
1126
+ smtp: "SMTP",
1127
+ sendgrid: "SendGrid",
1128
+ ses: "Amazon SES",
1129
+ postmark: "Postmark"
1130
+ }
1131
+ },
1132
+ smtp_host: { label: "Host", help: "Example: smtp.example.com" },
1133
+ smtp_port: { label: "Port" },
1134
+ smtp_secure: { label: "Use TLS" },
1135
+ smtp_user: { label: "Username" },
1136
+ smtp_password: { label: "Password" },
1137
+ api_key: { label: "API key" },
1138
+ from_email: { label: "From email", help: "Example: no-reply@example.com" },
1139
+ from_name: { label: "From name" }
1140
+ },
1141
+ actions: {
1142
+ test: { label: "Send test email" }
1143
+ }
1144
+ },
1145
+ branding: {
1146
+ title: "Branding",
1147
+ description: "Workspace name, logo, and accent colour.",
1148
+ groups: {
1149
+ identity: { title: "Identity" },
1150
+ appearance: { title: "Appearance" }
1151
+ },
1152
+ keys: {
1153
+ workspace_name: { label: "Workspace name" },
1154
+ support_email: { label: "Support email", help: "Example: support@example.com" },
1155
+ theme_mode: {
1156
+ label: "Default theme",
1157
+ options: { light: "Light", dark: "Dark", system: "Match system" }
1158
+ },
1159
+ accent_color: { label: "Accent colour" },
1160
+ logo_url: { label: "Logo URL", help: "Example: https://\u2026/logo.svg" }
1161
+ }
1162
+ },
1163
+ feature_flags: {
1164
+ title: "Feature Flags",
1165
+ description: "Toggle experimental and beta features for this workspace.",
1166
+ groups: {
1167
+ productivity: { title: "Productivity" },
1168
+ collaboration: { title: "Collaboration" }
1169
+ },
1170
+ keys: {
1171
+ ai_enabled: {
1172
+ label: "AI Assistant",
1173
+ help: "Enables the in-app AI assistant panel."
1174
+ },
1175
+ kanban_swimlanes: { label: "Kanban swimlanes" },
1176
+ realtime_cursors: { label: "Realtime cursors" },
1177
+ inline_comments: { label: "Inline comments" }
1178
+ }
1179
+ },
1180
+ storage: {
1181
+ title: "File Storage",
1182
+ description: "Backend used for attachments, exports, and user uploads. \u26A0 Switching adapter does not migrate existing files \u2014 files uploaded under the previous adapter become unreachable through the new one.",
1183
+ groups: {
1184
+ adapter: { title: "Backend", description: "Choose where uploaded files are stored." },
1185
+ local: { title: "Local" },
1186
+ s3: { title: "S3" },
1187
+ limits: { title: "Limits" }
1188
+ },
1189
+ keys: {
1190
+ adapter: {
1191
+ label: "Adapter",
1192
+ options: { local: "Local filesystem", s3: "S3 / S3-compatible" }
1193
+ },
1194
+ local_root: {
1195
+ label: "Root directory",
1196
+ help: "Filesystem path under which files are stored. Relative paths resolve from the server CWD."
1197
+ },
1198
+ s3_bucket: {
1199
+ label: "Bucket",
1200
+ help: "Shared host bucket. Per-project files are namespaced via the projects/<projectId>/ prefix."
1201
+ },
1202
+ s3_region: { label: "Region", help: "Example: us-east-1" },
1203
+ s3_endpoint: {
1204
+ label: "Endpoint",
1205
+ help: "Custom endpoint for S3-compatible providers (R2, MinIO, Wasabi). Leave blank for AWS S3."
1206
+ },
1207
+ s3_access_key_id: { label: "Access key ID" },
1208
+ s3_secret_access_key: { label: "Secret access key" },
1209
+ s3_force_path_style: {
1210
+ label: "Force path-style URLs",
1211
+ help: "Enable for MinIO and most S3-compatible providers; disable for AWS S3."
1212
+ },
1213
+ presigned_ttl: { label: "Presigned URL TTL (seconds)" },
1214
+ session_ttl: {
1215
+ label: "Upload session TTL (seconds)",
1216
+ help: "How long a chunked-upload session stays resumable."
1217
+ },
1218
+ max_upload_mb: { label: "Max upload size (MB)" }
1219
+ },
1220
+ actions: {
1221
+ test: { label: "Test connection" }
1222
+ }
1223
+ }
1224
+ }
1225
+ };
1226
+
1227
+ // src/translations/zh-CN.ts
1228
+ var zhCN = {
1229
+ settingsCommon: {
1230
+ sourceLabels: {
1231
+ env: "\u73AF\u5883\u53D8\u91CF",
1232
+ global: "\u5168\u5C40",
1233
+ tenant: "\u79DF\u6237",
1234
+ user: "\u7528\u6237",
1235
+ default: "\u9ED8\u8BA4"
1236
+ }
1237
+ },
1238
+ settings: {
1239
+ mail: {
1240
+ title: "\u90AE\u4EF6\u6295\u9012",
1241
+ description: "SMTP \u4E0E\u4E8B\u52A1\u6027\u90AE\u4EF6\u670D\u52A1\u5546\u914D\u7F6E\u3002",
1242
+ groups: {
1243
+ provider: { title: "\u670D\u52A1\u5546", description: "\u9009\u62E9\u6B64\u5DE5\u4F5C\u533A\u5982\u4F55\u53D1\u9001\u90AE\u4EF6\u3002" },
1244
+ smtp: { title: "SMTP" },
1245
+ api_key: { title: "API \u5BC6\u94A5" },
1246
+ from_address: { title: "\u53D1\u4EF6\u5730\u5740" }
1247
+ },
1248
+ keys: {
1249
+ provider: {
1250
+ label: "\u670D\u52A1\u5546",
1251
+ options: {
1252
+ smtp: "SMTP",
1253
+ sendgrid: "SendGrid",
1254
+ ses: "Amazon SES",
1255
+ postmark: "Postmark"
1256
+ }
1257
+ },
1258
+ smtp_host: { label: "\u4E3B\u673A", help: "\u793A\u4F8B:smtp.example.com" },
1259
+ smtp_port: { label: "\u7AEF\u53E3" },
1260
+ smtp_secure: { label: "\u542F\u7528 TLS" },
1261
+ smtp_user: { label: "\u7528\u6237\u540D" },
1262
+ smtp_password: { label: "\u5BC6\u7801" },
1263
+ api_key: { label: "API \u5BC6\u94A5" },
1264
+ from_email: { label: "\u53D1\u4EF6\u5730\u5740", help: "\u793A\u4F8B:no-reply@example.com" },
1265
+ from_name: { label: "\u53D1\u4EF6\u4EBA\u540D\u79F0" }
1266
+ },
1267
+ actions: {
1268
+ test: { label: "\u53D1\u9001\u6D4B\u8BD5\u90AE\u4EF6" }
1269
+ }
1270
+ },
1271
+ branding: {
1272
+ title: "\u54C1\u724C",
1273
+ description: "\u5DE5\u4F5C\u533A\u540D\u79F0\u3001Logo \u4E0E\u4E3B\u9898\u8272\u3002",
1274
+ groups: {
1275
+ identity: { title: "\u8EAB\u4EFD" },
1276
+ appearance: { title: "\u5916\u89C2" }
1277
+ },
1278
+ keys: {
1279
+ workspace_name: { label: "\u5DE5\u4F5C\u533A\u540D\u79F0" },
1280
+ support_email: { label: "\u5BA2\u670D\u90AE\u7BB1", help: "\u793A\u4F8B:support@example.com" },
1281
+ theme_mode: {
1282
+ label: "\u9ED8\u8BA4\u4E3B\u9898",
1283
+ options: { light: "\u6D45\u8272", dark: "\u6DF1\u8272", system: "\u8DDF\u968F\u7CFB\u7EDF" }
1284
+ },
1285
+ accent_color: { label: "\u4E3B\u9898\u8272" },
1286
+ logo_url: { label: "Logo \u94FE\u63A5", help: "\u793A\u4F8B:https://\u2026/logo.svg" }
1287
+ }
1288
+ },
1289
+ feature_flags: {
1290
+ title: "\u529F\u80FD\u5F00\u5173",
1291
+ description: "\u4E3A\u5F53\u524D\u5DE5\u4F5C\u533A\u5F00\u542F\u5B9E\u9A8C\u6027\u4E0E\u6D4B\u8BD5\u529F\u80FD\u3002",
1292
+ groups: {
1293
+ productivity: { title: "\u751F\u4EA7\u529B" },
1294
+ collaboration: { title: "\u534F\u4F5C" }
1295
+ },
1296
+ keys: {
1297
+ ai_enabled: {
1298
+ label: "AI \u52A9\u624B",
1299
+ help: "\u542F\u7528\u5E94\u7528\u5185 AI \u52A9\u624B\u9762\u677F\u3002"
1300
+ },
1301
+ kanban_swimlanes: { label: "\u770B\u677F\u6CF3\u9053" },
1302
+ realtime_cursors: { label: "\u5B9E\u65F6\u5149\u6807" },
1303
+ inline_comments: { label: "\u884C\u5185\u8BC4\u8BBA" }
1304
+ }
1305
+ },
1306
+ storage: {
1307
+ title: "\u6587\u4EF6\u5B58\u50A8",
1308
+ description: "\u9644\u4EF6\u3001\u5BFC\u51FA\u6587\u4EF6\u4E0E\u7528\u6237\u4E0A\u4F20\u6240\u4F7F\u7528\u7684\u5B58\u50A8\u540E\u7AEF\u3002\u26A0 \u5207\u6362\u9002\u914D\u5668\u4E0D\u4F1A\u8FC1\u79FB\u5DF2\u6709\u6587\u4EF6 \u2014\u2014 \u901A\u8FC7\u65E7\u9002\u914D\u5668\u4E0A\u4F20\u7684\u6587\u4EF6\uFF0C\u5728\u65B0\u9002\u914D\u5668\u4E2D\u5C06\u4E0D\u53EF\u8BBF\u95EE\u3002",
1309
+ groups: {
1310
+ adapter: { title: "\u5B58\u50A8\u540E\u7AEF", description: "\u9009\u62E9\u4E0A\u4F20\u6587\u4EF6\u7684\u5B58\u653E\u4F4D\u7F6E\u3002" },
1311
+ local: { title: "\u672C\u5730" },
1312
+ s3: { title: "S3" },
1313
+ limits: { title: "\u9650\u5236" }
1314
+ },
1315
+ keys: {
1316
+ adapter: {
1317
+ label: "\u9002\u914D\u5668",
1318
+ options: { local: "\u672C\u5730\u6587\u4EF6\u7CFB\u7EDF", s3: "S3 / S3 \u517C\u5BB9" }
1319
+ },
1320
+ local_root: {
1321
+ label: "\u6839\u76EE\u5F55",
1322
+ help: "\u6587\u4EF6\u5B58\u653E\u7684\u6587\u4EF6\u7CFB\u7EDF\u8DEF\u5F84\u3002\u76F8\u5BF9\u8DEF\u5F84\u76F8\u5BF9\u4E8E\u670D\u52A1\u8FDB\u7A0B\u7684\u5DE5\u4F5C\u76EE\u5F55\u3002"
1323
+ },
1324
+ s3_bucket: {
1325
+ label: "Bucket",
1326
+ help: "\u5171\u4EAB\u4E3B\u673A Bucket\u3002\u5404\u9879\u76EE\u7684\u6587\u4EF6\u901A\u8FC7 projects/<projectId>/ \u524D\u7F00\u8FDB\u884C\u9694\u79BB\u3002"
1327
+ },
1328
+ s3_region: { label: "\u533A\u57DF", help: "\u793A\u4F8B:us-east-1" },
1329
+ s3_endpoint: {
1330
+ label: "Endpoint",
1331
+ help: "S3 \u517C\u5BB9\u670D\u52A1(R2\u3001MinIO\u3001Wasabi)\u7684\u81EA\u5B9A\u4E49 Endpoint;AWS S3 \u8BF7\u7559\u7A7A\u3002"
1332
+ },
1333
+ s3_access_key_id: { label: "Access Key ID" },
1334
+ s3_secret_access_key: { label: "Secret Access Key" },
1335
+ s3_force_path_style: {
1336
+ label: "\u5F3A\u5236\u8DEF\u5F84\u98CE\u683C URL",
1337
+ help: "MinIO \u4E0E\u5927\u591A\u6570 S3 \u517C\u5BB9\u670D\u52A1\u8BF7\u5F00\u542F;AWS S3 \u8BF7\u5173\u95ED\u3002"
1338
+ },
1339
+ presigned_ttl: { label: "\u9884\u7B7E\u540D URL \u6709\u6548\u671F(\u79D2)" },
1340
+ session_ttl: {
1341
+ label: "\u5206\u7247\u4E0A\u4F20\u4F1A\u8BDD\u6709\u6548\u671F(\u79D2)",
1342
+ help: "\u5206\u7247\u4E0A\u4F20\u4F1A\u8BDD\u4FDD\u6301\u53EF\u7EED\u4F20\u7684\u65F6\u957F\u3002"
1343
+ },
1344
+ max_upload_mb: { label: "\u5355\u6587\u4EF6\u6700\u5927\u4E0A\u4F20(MB)" }
1345
+ },
1346
+ actions: {
1347
+ test: { label: "\u6D4B\u8BD5\u8FDE\u63A5" }
1348
+ }
1349
+ }
1350
+ }
1351
+ };
1352
+
1353
+ // src/translations/ja-JP.ts
1354
+ var jaJP = {
1355
+ settingsCommon: {
1356
+ sourceLabels: {
1357
+ env: "\u74B0\u5883\u5909\u6570",
1358
+ global: "\u30B0\u30ED\u30FC\u30D0\u30EB",
1359
+ tenant: "\u30C6\u30CA\u30F3\u30C8",
1360
+ user: "\u30E6\u30FC\u30B6\u30FC",
1361
+ default: "\u30C7\u30D5\u30A9\u30EB\u30C8"
1362
+ }
1363
+ },
1364
+ settings: {
1365
+ mail: {
1366
+ title: "\u30E1\u30FC\u30EB\u914D\u4FE1",
1367
+ description: "SMTP \u304A\u3088\u3073\u30C8\u30E9\u30F3\u30B6\u30AF\u30B7\u30E7\u30F3\u30E1\u30FC\u30EB\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u8A2D\u5B9A\u3002",
1368
+ groups: {
1369
+ provider: { title: "\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC", description: "\u3053\u306E\u30EF\u30FC\u30AF\u30B9\u30DA\u30FC\u30B9\u306E\u9001\u4FE1\u65B9\u6CD5\u3092\u9078\u629E\u3057\u307E\u3059\u3002" },
1370
+ smtp: { title: "SMTP" },
1371
+ api_key: { title: "API \u30AD\u30FC" },
1372
+ from_address: { title: "\u5DEE\u51FA\u4EBA\u30A2\u30C9\u30EC\u30B9" }
1373
+ },
1374
+ keys: {
1375
+ provider: {
1376
+ label: "\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC",
1377
+ options: {
1378
+ smtp: "SMTP",
1379
+ sendgrid: "SendGrid",
1380
+ ses: "Amazon SES",
1381
+ postmark: "Postmark"
1382
+ }
1383
+ },
1384
+ smtp_host: { label: "\u30DB\u30B9\u30C8", help: "\u4F8B: smtp.example.com" },
1385
+ smtp_port: { label: "\u30DD\u30FC\u30C8" },
1386
+ smtp_secure: { label: "TLS \u3092\u4F7F\u7528" },
1387
+ smtp_user: { label: "\u30E6\u30FC\u30B6\u30FC\u540D" },
1388
+ smtp_password: { label: "\u30D1\u30B9\u30EF\u30FC\u30C9" },
1389
+ api_key: { label: "API \u30AD\u30FC" },
1390
+ from_email: { label: "\u5DEE\u51FA\u4EBA\u30A2\u30C9\u30EC\u30B9", help: "\u4F8B: no-reply@example.com" },
1391
+ from_name: { label: "\u5DEE\u51FA\u4EBA\u540D" }
1392
+ },
1393
+ actions: {
1394
+ test: { label: "\u30C6\u30B9\u30C8\u30E1\u30FC\u30EB\u9001\u4FE1" }
1395
+ }
1396
+ },
1397
+ branding: {
1398
+ title: "\u30D6\u30E9\u30F3\u30C7\u30A3\u30F3\u30B0",
1399
+ description: "\u30EF\u30FC\u30AF\u30B9\u30DA\u30FC\u30B9\u540D\u30FB\u30ED\u30B4\u30FB\u30A2\u30AF\u30BB\u30F3\u30C8\u30AB\u30E9\u30FC\u3002",
1400
+ groups: {
1401
+ identity: { title: "\u30A2\u30A4\u30C7\u30F3\u30C6\u30A3\u30C6\u30A3" },
1402
+ appearance: { title: "\u5916\u89B3" }
1403
+ },
1404
+ keys: {
1405
+ workspace_name: { label: "\u30EF\u30FC\u30AF\u30B9\u30DA\u30FC\u30B9\u540D" },
1406
+ support_email: { label: "\u30B5\u30DD\u30FC\u30C8\u30E1\u30FC\u30EB", help: "\u4F8B: support@example.com" },
1407
+ theme_mode: {
1408
+ label: "\u30C7\u30D5\u30A9\u30EB\u30C8\u30C6\u30FC\u30DE",
1409
+ options: { light: "\u30E9\u30A4\u30C8", dark: "\u30C0\u30FC\u30AF", system: "\u30B7\u30B9\u30C6\u30E0\u306B\u5F93\u3046" }
1410
+ },
1411
+ accent_color: { label: "\u30A2\u30AF\u30BB\u30F3\u30C8\u30AB\u30E9\u30FC" },
1412
+ logo_url: { label: "\u30ED\u30B4 URL", help: "\u4F8B: https://\u2026/logo.svg" }
1413
+ }
1414
+ },
1415
+ feature_flags: {
1416
+ title: "\u6A5F\u80FD\u30D5\u30E9\u30B0",
1417
+ description: "\u3053\u306E\u30EF\u30FC\u30AF\u30B9\u30DA\u30FC\u30B9\u3067\u5B9F\u9A13\u7684\u30FB\u30D9\u30FC\u30BF\u6A5F\u80FD\u3092\u5207\u66FF\u3048\u307E\u3059\u3002",
1418
+ groups: {
1419
+ productivity: { title: "\u751F\u7523\u6027" },
1420
+ collaboration: { title: "\u30B3\u30E9\u30DC\u30EC\u30FC\u30B7\u30E7\u30F3" }
1421
+ },
1422
+ keys: {
1423
+ ai_enabled: {
1424
+ label: "AI \u30A2\u30B7\u30B9\u30BF\u30F3\u30C8",
1425
+ help: "\u30A2\u30D7\u30EA\u5185 AI \u30A2\u30B7\u30B9\u30BF\u30F3\u30C8\u30D1\u30CD\u30EB\u3092\u6709\u52B9\u5316\u3057\u307E\u3059\u3002"
1426
+ },
1427
+ kanban_swimlanes: { label: "\u30AB\u30F3\u30D0\u30F3\u306E\u30B9\u30A4\u30E0\u30EC\u30FC\u30F3" },
1428
+ realtime_cursors: { label: "\u30EA\u30A2\u30EB\u30BF\u30A4\u30E0\u30AB\u30FC\u30BD\u30EB" },
1429
+ inline_comments: { label: "\u30A4\u30F3\u30E9\u30A4\u30F3\u30B3\u30E1\u30F3\u30C8" }
1430
+ }
1431
+ },
1432
+ storage: {
1433
+ title: "\u30D5\u30A1\u30A4\u30EB\u30B9\u30C8\u30EC\u30FC\u30B8",
1434
+ description: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB\u30FB\u30A8\u30AF\u30B9\u30DD\u30FC\u30C8\u30FB\u30E6\u30FC\u30B6\u30FC\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u306B\u4F7F\u7528\u3059\u308B\u30D0\u30C3\u30AF\u30A8\u30F3\u30C9\u3002\u26A0 \u30A2\u30C0\u30D7\u30BF\u30FC\u3092\u5207\u66FF\u3048\u3066\u3082\u65E2\u5B58\u30D5\u30A1\u30A4\u30EB\u306F\u79FB\u884C\u3055\u308C\u307E\u305B\u3093\u3002\u4EE5\u524D\u306E\u30A2\u30C0\u30D7\u30BF\u30FC\u3067\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u3055\u308C\u305F\u30D5\u30A1\u30A4\u30EB\u306F\u65B0\u3057\u3044\u30A2\u30C0\u30D7\u30BF\u30FC\u304B\u3089\u30A2\u30AF\u30BB\u30B9\u3067\u304D\u306A\u304F\u306A\u308A\u307E\u3059\u3002",
1435
+ groups: {
1436
+ adapter: { title: "\u30D0\u30C3\u30AF\u30A8\u30F3\u30C9", description: "\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u30D5\u30A1\u30A4\u30EB\u306E\u4FDD\u5B58\u5148\u3092\u9078\u629E\u3057\u307E\u3059\u3002" },
1437
+ local: { title: "\u30ED\u30FC\u30AB\u30EB" },
1438
+ s3: { title: "S3" },
1439
+ limits: { title: "\u5236\u9650" }
1440
+ },
1441
+ keys: {
1442
+ adapter: {
1443
+ label: "\u30A2\u30C0\u30D7\u30BF\u30FC",
1444
+ options: { local: "\u30ED\u30FC\u30AB\u30EB\u30D5\u30A1\u30A4\u30EB\u30B7\u30B9\u30C6\u30E0", s3: "S3 / S3 \u4E92\u63DB" }
1445
+ },
1446
+ local_root: {
1447
+ label: "\u30EB\u30FC\u30C8\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA",
1448
+ help: "\u30D5\u30A1\u30A4\u30EB\u3092\u4FDD\u5B58\u3059\u308B\u30D5\u30A1\u30A4\u30EB\u30B7\u30B9\u30C6\u30E0\u30D1\u30B9\u3002\u76F8\u5BFE\u30D1\u30B9\u306F\u30B5\u30FC\u30D0\u30FC\u306E CWD \u304B\u3089\u89E3\u6C7A\u3055\u308C\u307E\u3059\u3002"
1449
+ },
1450
+ s3_bucket: {
1451
+ label: "\u30D0\u30B1\u30C3\u30C8",
1452
+ help: "\u5171\u6709\u30DB\u30B9\u30C8\u30D0\u30B1\u30C3\u30C8\u3002\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8\u6BCE\u306E\u30D5\u30A1\u30A4\u30EB\u306F projects/<projectId>/ \u30D7\u30EC\u30D5\u30A3\u30C3\u30AF\u30B9\u3067\u5206\u96E2\u3055\u308C\u307E\u3059\u3002"
1453
+ },
1454
+ s3_region: { label: "\u30EA\u30FC\u30B8\u30E7\u30F3", help: "\u4F8B: us-east-1" },
1455
+ s3_endpoint: {
1456
+ label: "\u30A8\u30F3\u30C9\u30DD\u30A4\u30F3\u30C8",
1457
+ help: "S3 \u4E92\u63DB\u30D7\u30ED\u30D0\u30A4\u30C0 (R2, MinIO, Wasabi) \u306E\u30AB\u30B9\u30BF\u30E0\u30A8\u30F3\u30C9\u30DD\u30A4\u30F3\u30C8\u3002AWS S3 \u306E\u5834\u5408\u306F\u7A7A\u6B04\u3002"
1458
+ },
1459
+ s3_access_key_id: { label: "\u30A2\u30AF\u30BB\u30B9\u30AD\u30FC ID" },
1460
+ s3_secret_access_key: { label: "\u30B7\u30FC\u30AF\u30EC\u30C3\u30C8\u30A2\u30AF\u30BB\u30B9\u30AD\u30FC" },
1461
+ s3_force_path_style: {
1462
+ label: "\u30D1\u30B9\u30B9\u30BF\u30A4\u30EB URL \u3092\u5F37\u5236",
1463
+ help: "MinIO \u3084\u591A\u304F\u306E S3 \u4E92\u63DB\u30D7\u30ED\u30D0\u30A4\u30C0\u3067\u6709\u52B9\u5316\u3002AWS S3 \u3067\u306F\u7121\u52B9\u5316\u3002"
1464
+ },
1465
+ presigned_ttl: { label: "\u7F72\u540D\u4ED8\u304D URL \u306E\u6709\u52B9\u671F\u9593 (\u79D2)" },
1466
+ session_ttl: {
1467
+ label: "\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u30BB\u30C3\u30B7\u30E7\u30F3 TTL (\u79D2)",
1468
+ help: "\u30C1\u30E3\u30F3\u30AF\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u306E\u518D\u958B\u53EF\u80FD\u671F\u9593\u3002"
1469
+ },
1470
+ max_upload_mb: { label: "\u6700\u5927\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u30B5\u30A4\u30BA (MB)" }
1471
+ },
1472
+ actions: {
1473
+ test: { label: "\u63A5\u7D9A\u30C6\u30B9\u30C8" }
1474
+ }
1475
+ }
1476
+ }
1477
+ };
1478
+
1479
+ // src/translations/index.ts
1480
+ var settingsBuiltinTranslations = {
1481
+ en,
1482
+ "zh-CN": zhCN,
1483
+ "ja-JP": jaJP
1484
+ };
1485
+
1486
+ // src/settings-service-plugin.ts
1487
+ var SettingsServicePlugin = class {
1488
+ constructor(opts = {}) {
1489
+ this.name = SETTINGS_PLUGIN_ID;
1490
+ this.version = SETTINGS_PLUGIN_VERSION;
1491
+ this.type = "standard";
1492
+ this.service = null;
1493
+ this.opts = {
1494
+ ...opts,
1495
+ manifests: opts.manifests ?? builtinSettingsManifests,
1496
+ actionHandlers: opts.actionHandlers ?? {
1497
+ mail: { test: mailTestActionHandler },
1498
+ storage: { test: storageTestActionHandler }
1499
+ }
1500
+ };
1501
+ }
1502
+ async init(ctx) {
1503
+ this.service = new SettingsService({
1504
+ crypto: this.opts.crypto,
1505
+ env: this.opts.env
1506
+ });
1507
+ for (const m of this.opts.manifests ?? []) this.service.registerManifest(m);
1508
+ for (const [ns, handlers] of Object.entries(this.opts.actionHandlers ?? {})) {
1509
+ for (const [id, fn] of Object.entries(handlers)) {
1510
+ this.service.registerAction(ns, id, fn);
1511
+ }
1512
+ }
1513
+ ctx.registerService("settings", this.service);
1514
+ ctx.logger?.info?.(
1515
+ `SettingsServicePlugin: registered (manifests=${this.opts.manifests?.length ?? 0})`
1516
+ );
1517
+ try {
1518
+ ctx.getService("manifest").register({
1519
+ ...settingsPluginManifestHeader,
1520
+ objects: settingsObjects
1521
+ });
1522
+ } catch {
1523
+ }
1524
+ }
1525
+ async start(ctx) {
1526
+ if (!this.service) return;
1527
+ ctx.hook("kernel:ready", async () => {
1528
+ try {
1529
+ const i18n = ctx.getService("i18n");
1530
+ let loaded = 0;
1531
+ for (const [locale, data] of Object.entries(settingsBuiltinTranslations)) {
1532
+ if (data && typeof data === "object") {
1533
+ try {
1534
+ i18n.loadTranslations(locale, data);
1535
+ loaded++;
1536
+ } catch (err) {
1537
+ ctx.logger?.warn?.(
1538
+ `SettingsServicePlugin: failed to load translations for '${locale}': ${err?.message ?? err}`
1539
+ );
1540
+ }
1541
+ }
1542
+ }
1543
+ if (loaded > 0) {
1544
+ ctx.logger?.info?.(
1545
+ `SettingsServicePlugin: contributed built-in translations (${loaded} locale${loaded > 1 ? "s" : ""})`
1546
+ );
1547
+ }
1548
+ } catch {
1549
+ }
1550
+ let engine = null;
1551
+ try {
1552
+ engine = ctx.getService("objectql");
1553
+ } catch {
1554
+ }
1555
+ if (engine) {
1556
+ this.service.bindEngine(
1557
+ engine,
1558
+ this.buildAuditSink(ctx, engine),
1559
+ {
1560
+ secretStore: this.buildSecretStore(engine),
1561
+ auditWriter: this.buildAuditWriter(ctx, engine),
1562
+ cryptoProvider: this.opts.cryptoProvider ?? new InMemoryCryptoProvider()
1563
+ }
1564
+ );
1565
+ }
1566
+ if (this.opts.registerRoutes === false) return;
1567
+ let http = null;
1568
+ try {
1569
+ http = ctx.getService("http-server");
1570
+ } catch {
1571
+ }
1572
+ if (!http) {
1573
+ ctx.logger?.warn?.(
1574
+ 'SettingsServicePlugin: no HTTP server available \u2014 REST routes not registered. SettingsService is still reachable via kernel.getService("settings").'
1575
+ );
1576
+ return;
1577
+ }
1578
+ registerSettingsRoutes(http, this.service, { basePath: this.opts.basePath });
1579
+ ctx.logger?.info?.(
1580
+ "SettingsServicePlugin: REST routes registered at " + (this.opts.basePath ?? "/api/settings")
1581
+ );
1582
+ });
1583
+ }
1584
+ /** Glue an `engine.insert('sys_audit_log', …)` audit sink. */
1585
+ buildAuditSink(ctx, engine) {
1586
+ return {
1587
+ record: async (entry) => {
1588
+ try {
1589
+ await engine.insert?.("sys_audit_log", {
1590
+ actor_id: entry.userId ?? null,
1591
+ entity_type: "sys_setting",
1592
+ entity_id: `${entry.namespace}.${entry.key}`,
1593
+ action: entry.action,
1594
+ payload: {
1595
+ namespace: entry.namespace,
1596
+ key: entry.key,
1597
+ scope: entry.scope,
1598
+ encrypted: entry.encrypted,
1599
+ digest: entry.valueDigest
1600
+ },
1601
+ request_id: entry.requestId ?? null,
1602
+ occurred_at: (/* @__PURE__ */ new Date()).toISOString()
1603
+ });
1604
+ } catch (err) {
1605
+ ctx.logger?.warn?.("SettingsServicePlugin: audit record failed: " + (err?.message ?? err));
1606
+ }
1607
+ }
1608
+ };
1609
+ }
1610
+ /**
1611
+ * Phase 3: build a `sys_secret`-backed implementation of
1612
+ * `SettingsSecretStore`. The store bypasses the tenant audit
1613
+ * warning because secrets are scoped through their owning
1614
+ * `sys_setting` row (which already carries the tenant context).
1615
+ */
1616
+ buildSecretStore(engine) {
1617
+ const eng = engine;
1618
+ return {
1619
+ async insert(row) {
1620
+ await eng.insert("sys_secret", row, { bypassTenantAudit: true });
1621
+ return { id: row.id };
1622
+ },
1623
+ async get(id) {
1624
+ const rows = await eng.find("sys_secret", {
1625
+ where: { id },
1626
+ limit: 1,
1627
+ bypassTenantAudit: true
1628
+ });
1629
+ const row = Array.isArray(rows) ? rows[0] : rows?.data?.[0];
1630
+ return row ?? null;
1631
+ },
1632
+ async update(id, patch) {
1633
+ await eng.update("sys_secret", {
1634
+ where: { id },
1635
+ data: patch,
1636
+ bypassTenantAudit: true
1637
+ });
1638
+ }
1639
+ };
1640
+ }
1641
+ /**
1642
+ * Phase 3: append-only writer for `sys_setting_audit`. Failures here
1643
+ * MUST NOT abort the settings write, so all calls are wrapped in a
1644
+ * try/catch and reported through the plugin logger.
1645
+ */
1646
+ buildAuditWriter(ctx, engine) {
1647
+ const eng = engine;
1648
+ return {
1649
+ write: async (entry) => {
1650
+ try {
1651
+ await eng.insert("sys_setting_audit", {
1652
+ namespace: entry.namespace,
1653
+ key: entry.key,
1654
+ scope: entry.scope,
1655
+ action: entry.action,
1656
+ source: entry.source ?? "api",
1657
+ actor_id: entry.actorId ?? null,
1658
+ old_hash: entry.oldHash ?? null,
1659
+ new_hash: entry.newHash ?? null,
1660
+ encrypted: !!entry.encrypted,
1661
+ request_id: entry.requestId ?? null,
1662
+ reason: entry.reason ?? null,
1663
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
1664
+ }, { bypassTenantAudit: true });
1665
+ } catch (err) {
1666
+ ctx.logger?.warn?.("SettingsServicePlugin: setting-audit write failed: " + (err?.message ?? err));
1667
+ }
1668
+ }
1669
+ };
1670
+ }
1671
+ };
1672
+ export {
1673
+ NoopCryptoAdapter,
1674
+ SETTINGS_PLUGIN_ID,
1675
+ SETTINGS_PLUGIN_VERSION,
1676
+ SettingsLockedError,
1677
+ SettingsService,
1678
+ SettingsServicePlugin,
1679
+ UnknownKeyError,
1680
+ UnknownNamespaceError,
1681
+ brandingSettingsManifest,
1682
+ builtinSettingsManifests,
1683
+ envKeyOf,
1684
+ featureFlagsSettingsManifest,
1685
+ mailSettingsManifest,
1686
+ mailTestActionHandler,
1687
+ registerSettingsRoutes,
1688
+ settingsBuiltinTranslations,
1689
+ settingsObjects,
1690
+ settingsPluginManifestHeader,
1691
+ en as settingsTranslationsEn,
1692
+ jaJP as settingsTranslationsJaJP,
1693
+ zhCN as settingsTranslationsZhCN,
1694
+ storageSettingsManifest,
1695
+ storageTestActionHandler
1696
+ };
1697
+ //# sourceMappingURL=index.js.map