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