@k2b/cloud 0.46.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- package/src/_internal/app-access-manager-sql.ts +10 -0
- package/src/_internal/bounded-json.ts +7 -1
- package/src/_internal/capabilities.ts +8 -0
- package/src/_internal/define-app.ts +69 -2
- package/src/_internal/heartbeat.ts +21 -0
- package/src/_internal/nats-connection.ts +10 -9
- package/src/_internal/postgres-application-name.ts +11 -0
- package/src/_internal/process-sync.ts +15 -6
- package/src/_internal/public-surface.ts +76 -0
- package/src/_internal/purpose-name.ts +7 -0
- package/src/_internal/readiness.ts +6 -1
- package/src/_internal/registry-validation.ts +1 -0
- package/src/_internal/registry.ts +49 -3
- package/src/_internal/resource-principals.ts +4 -34
- package/src/_internal/workload-credential.ts +154 -0
- package/src/_internal/workload-protocol.ts +36 -0
- package/src/ai/application-tasks.ts +5 -2
- package/src/ai/chart-block.ts +36 -711
- package/src/ai/default-tool-contracts.ts +21 -259
- package/src/api/admin-app-domains.ts +62 -0
- package/src/api/admin-app-keys.ts +66 -0
- package/src/api/admin-core-settings.ts +2 -0
- package/src/api/admin-identity.ts +27 -1
- package/src/api/admin-settings-env.ts +120 -0
- package/src/api/app-bootstrap.ts +131 -0
- package/src/api/capabilities.ts +17 -4
- package/src/api/index.ts +8 -0
- package/src/api/settings-ops.ts +35 -0
- package/src/app-key.ts +125 -0
- package/src/browser/CloudResourceSearch.tsx +48 -41
- package/src/capabilities/discussion-providers.ts +3 -2
- package/src/capabilities/resource-readers.ts +13 -2
- package/src/capabilities/response.ts +6 -2
- package/src/capabilities/server-invoke.ts +73 -29
- package/src/capabilities/server.ts +1 -1
- package/src/capabilities/types.ts +1 -1
- package/src/cli/admin/app-credentials.ts +32 -7
- package/src/cli/admin/app-domains.ts +140 -0
- package/src/cli/admin/app-keys.ts +226 -0
- package/src/cli/admin/index.ts +109 -84
- package/src/cli/admin/settings.ts +62 -0
- package/src/config/define-env.ts +63 -8
- package/src/config/env.ts +34 -22
- package/src/config/index.ts +12 -1
- package/src/config/workflow-ai.ts +28 -0
- package/src/contracts/app-domains.ts +91 -0
- package/src/contracts/app-status.ts +104 -0
- package/src/contracts/capabilities.ts +22 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/index.ts +10 -0
- package/src/runtime/bootstrap-client.ts +195 -0
- package/src/runtime/bootstrap-protocol.ts +60 -0
- package/src/runtime/clients.ts +72 -0
- package/src/runtime/connections.ts +46 -0
- package/src/runtime/resolve-connections.ts +91 -0
- package/src/server/app-key.ts +14 -0
- package/src/server/client-ip.ts +1 -1
- package/src/server/index.ts +1 -0
- package/src/services/app-access-management.ts +1 -8
- package/src/services/app-admin-facts.ts +160 -0
- package/src/services/app-bootstrap.ts +134 -0
- package/src/services/app-domain-check.ts +152 -0
- package/src/services/app-domains.ts +172 -0
- package/src/services/app-keys.ts +301 -0
- package/src/services/downstream-budget.ts +2 -2
- package/src/services/gateway.ts +6 -0
- package/src/services/identity/index.ts +3 -0
- package/src/services/identity/resource-readers.ts +80 -0
- package/src/services/identity/runtime-config.ts +14 -6
- package/src/services/identity/workload-runtime.ts +253 -0
- package/src/services/index.ts +8 -1
- package/src/services/links/index.ts +16 -0
- package/src/services/links/runtime.ts +44 -0
- package/src/services/links/store.ts +105 -0
- package/src/services/logging/index.ts +7 -5
- package/src/services/logging/redaction.ts +6 -1
- package/src/services/logging/trace.ts +8 -8
- package/src/services/outgoing-mail/resolve-host.ts +5 -4
- package/src/services/request-cache-redis.ts +2 -2
- package/src/services/service-account-credentials.ts +63 -21
- package/src/services/settings/api.ts +3 -3
- package/src/services/settings/binding.ts +45 -0
- package/src/services/settings/core-settings.ts +119 -1
- package/src/services/settings/crypto.ts +2 -2
- package/src/services/settings/defaults.ts +16 -2
- package/src/services/settings/env-status.ts +69 -0
- package/src/services/settings/index.ts +69 -26
- package/src/services/settings/store.ts +78 -8
- package/src/services/settings/validation.ts +40 -0
- package/src/shared/index.ts +11 -10
- package/src/shared/markdown/extensions/tables.ts +10 -1
- package/src/ssr/admin-navigation.ts +0 -1
- package/src/ssr/platform-messages.ts +0 -2
- package/src/start.ts +57 -0
- package/src/workflows/ai/index.ts +1 -0
- package/src/workflows/ai/runtime.ts +16 -8
- package/src/workflows/ai/settings.ts +5 -0
- package/src/shared/markdown/formula.ts +0 -1283
|
@@ -221,10 +221,10 @@ const verifyApiTokenSecret = async (secret: string, storedHash: string, scopes:
|
|
|
221
221
|
}
|
|
222
222
|
};
|
|
223
223
|
|
|
224
|
-
const generateUniqueTokenParts = async (): Promise<{ tokenPrefix: string; secret: string; token: string }> => {
|
|
224
|
+
const generateUniqueTokenParts = async (db: SqlRunner): Promise<{ tokenPrefix: string; secret: string; token: string }> => {
|
|
225
225
|
for (let i = 0; i < 5; i += 1) {
|
|
226
226
|
const parts = generateTokenParts();
|
|
227
|
-
const [row] = await
|
|
227
|
+
const [row] = await db<{ exists: boolean }[]>`
|
|
228
228
|
SELECT EXISTS(
|
|
229
229
|
SELECT 1 FROM auth.service_account_credentials WHERE token_prefix = ${parts.tokenPrefix}
|
|
230
230
|
) AS exists
|
|
@@ -342,7 +342,7 @@ const insertApiToken = async (
|
|
|
342
342
|
if (expiresAt.getTime() <= Date.now()) return fail(err.badInput("Expiry must be in the future"));
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
-
const parts = await generateUniqueTokenParts();
|
|
345
|
+
const parts = await generateUniqueTokenParts(db);
|
|
346
346
|
const secretHash = hashApiTokenSecret(parts.secret, params.scopes ?? []);
|
|
347
347
|
|
|
348
348
|
try {
|
|
@@ -376,15 +376,18 @@ const insertApiToken = async (
|
|
|
376
376
|
}
|
|
377
377
|
};
|
|
378
378
|
|
|
379
|
-
export const createApiToken = async (
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
379
|
+
export const createApiToken = async (
|
|
380
|
+
params: {
|
|
381
|
+
serviceAccountId: string;
|
|
382
|
+
name: string;
|
|
383
|
+
createdBy?: string | null;
|
|
384
|
+
expiresAt?: string | null;
|
|
385
|
+
scopes?: string[];
|
|
386
|
+
},
|
|
387
|
+
options: { db?: SQL } = {},
|
|
388
|
+
): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
|
|
386
389
|
if (params.scopes?.includes(AI_INFERENCE_SCOPE)) return fail(err.badInput("ai:inference is reserved for models-only personal API keys"));
|
|
387
|
-
return insertApiToken(sql, params);
|
|
390
|
+
return insertApiToken(options.db ?? sql, params);
|
|
388
391
|
};
|
|
389
392
|
|
|
390
393
|
export const createUserApiToken = async (params: {
|
|
@@ -531,7 +534,7 @@ export const listForDelegatedUser = async (params: { userId: string }): Promise<
|
|
|
531
534
|
c.last_used_at, c.created_by, c.created_at, c.revoked_at, c.revoked_by
|
|
532
535
|
FROM auth.service_account_credentials c
|
|
533
536
|
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
534
|
-
WHERE sa.kind = 'user_delegated'
|
|
537
|
+
WHERE c.kind = 'api_token' AND sa.kind = 'user_delegated'
|
|
535
538
|
AND sa.delegated_user_id = ${params.userId}::uuid
|
|
536
539
|
AND c.status = 'active'
|
|
537
540
|
ORDER BY c.created_at DESC
|
|
@@ -569,7 +572,7 @@ export const listOverview = async (config?: {
|
|
|
569
572
|
FROM auth.service_account_credentials c
|
|
570
573
|
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
571
574
|
LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
|
|
572
|
-
WHERE (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
|
|
575
|
+
WHERE c.kind = 'api_token' AND (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
|
|
573
576
|
AND (${credentialStatus}::text IS NULL OR c.status = ${credentialStatus})
|
|
574
577
|
AND (${userId}::uuid IS NULL OR sa.delegated_user_id = ${userId}::uuid)
|
|
575
578
|
AND (${serviceAccountId}::uuid IS NULL OR sa.id = ${serviceAccountId}::uuid)
|
|
@@ -621,7 +624,7 @@ export const listOverview = async (config?: {
|
|
|
621
624
|
FROM auth.service_account_credentials c
|
|
622
625
|
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
623
626
|
LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
|
|
624
|
-
WHERE (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
|
|
627
|
+
WHERE c.kind = 'api_token' AND (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
|
|
625
628
|
AND (${credentialStatus}::text IS NULL OR c.status = ${credentialStatus})
|
|
626
629
|
AND (${userId}::uuid IS NULL OR sa.delegated_user_id = ${userId}::uuid)
|
|
627
630
|
AND (${serviceAccountId}::uuid IS NULL OR sa.id = ${serviceAccountId}::uuid)
|
|
@@ -688,7 +691,7 @@ export const getOverview = async (params: { id: string }): Promise<ServiceAccoun
|
|
|
688
691
|
FROM auth.service_account_credentials c
|
|
689
692
|
JOIN auth.service_accounts sa ON sa.id = c.service_account_id
|
|
690
693
|
LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
|
|
691
|
-
WHERE c.id = ${params.id}::uuid
|
|
694
|
+
WHERE c.kind = 'api_token' AND c.id = ${params.id}::uuid
|
|
692
695
|
LIMIT 1
|
|
693
696
|
`;
|
|
694
697
|
return row ? mapCredentialOverview(row) : null;
|
|
@@ -732,14 +735,14 @@ type RevokeCredentialInput =
|
|
|
732
735
|
export const revoke = async (params: RevokeCredentialInput, options: { db?: SQL } = {}): Promise<Result<void>> => {
|
|
733
736
|
if (!UUID_PATTERN.test(params.credentialId)) return fail(err.notFound("API key"));
|
|
734
737
|
|
|
735
|
-
const run = async (db: SQL): Promise<Result<void>> => {
|
|
738
|
+
const run = async (db: SQL, credentialId = params.credentialId): Promise<Result<void>> => {
|
|
736
739
|
const [row] = await db<DbCredentialRow[]>`
|
|
737
740
|
UPDATE auth.service_account_credentials
|
|
738
741
|
SET status = 'revoked',
|
|
739
742
|
revoked_at = now(),
|
|
740
743
|
revoked_by = ${params.actor?.id ?? null}::uuid
|
|
741
|
-
WHERE id = ${
|
|
742
|
-
AND status = 'active'
|
|
744
|
+
WHERE id = ${credentialId}::uuid
|
|
745
|
+
AND status = 'active' AND kind = 'api_token'
|
|
743
746
|
RETURNING id, service_account_id, name, kind, status, token_prefix, scopes, expires_at,
|
|
744
747
|
last_used_at, created_by, created_at, revoked_at, revoked_by
|
|
745
748
|
`;
|
|
@@ -750,14 +753,14 @@ export const revoke = async (params: RevokeCredentialInput, options: { db?: SQL
|
|
|
750
753
|
SELECT id, service_account_id, name, kind, status, token_prefix, scopes, expires_at,
|
|
751
754
|
last_used_at, created_by, created_at, revoked_at, revoked_by
|
|
752
755
|
FROM auth.service_account_credentials
|
|
753
|
-
WHERE id = ${
|
|
756
|
+
WHERE id = ${credentialId}::uuid AND kind = 'api_token'
|
|
754
757
|
LIMIT 1
|
|
755
758
|
`;
|
|
756
759
|
const result = row || existing?.status === "revoked" ? ok() : fail(err.notFound("API key"));
|
|
757
760
|
return audit.recordResult({
|
|
758
761
|
action: "service_account_credential.revoke",
|
|
759
762
|
actor: params.actor ? actorForUser(params.actor) : null,
|
|
760
|
-
target: { type: "service_account_credential", id:
|
|
763
|
+
target: { type: "service_account_credential", id: credentialId, label: row?.name ?? existing?.name ?? null },
|
|
761
764
|
metadata: {
|
|
762
765
|
serviceAccountId: row?.service_account_id ?? existing?.service_account_id ?? null,
|
|
763
766
|
...(params.actor ? { adminAction: true } : { provenance: "system", service: params.system.service, reason: params.system.reason }),
|
|
@@ -766,7 +769,46 @@ export const revoke = async (params: RevokeCredentialInput, options: { db?: SQL
|
|
|
766
769
|
db,
|
|
767
770
|
});
|
|
768
771
|
};
|
|
769
|
-
|
|
772
|
+
const revokeWithWorkload = async (db: SQL): Promise<Result<void>> => {
|
|
773
|
+
if (params.actor) {
|
|
774
|
+
const [instance] = await db<{ app_id: string; instance_id: string; service_account_id: string }[]>`
|
|
775
|
+
SELECT i.app_id, i.instance_id, c.service_account_id FROM auth.app_workload_instances i
|
|
776
|
+
JOIN auth.service_account_credentials c ON c.id = i.credential_id
|
|
777
|
+
WHERE i.credential_id = ${params.credentialId}::uuid OR i.previous_credential_id = ${params.credentialId}::uuid
|
|
778
|
+
`;
|
|
779
|
+
if (instance) {
|
|
780
|
+
// Same lock order as Core rotation. Resolve the latest generation *after* waiting,
|
|
781
|
+
// so an admin revoking the displayed token also revokes an overlapping rotation.
|
|
782
|
+
await db`SELECT id FROM auth.service_accounts WHERE id = ${instance.service_account_id}::uuid FOR UPDATE`;
|
|
783
|
+
// The account is the durable app-wide off switch, including future process instances.
|
|
784
|
+
await db`UPDATE auth.service_accounts SET status = 'disabled' WHERE id = ${instance.service_account_id}::uuid`;
|
|
785
|
+
await audit.record(
|
|
786
|
+
{
|
|
787
|
+
action: "service_account.disable",
|
|
788
|
+
outcome: "allowed",
|
|
789
|
+
actor: actorForUser(params.actor),
|
|
790
|
+
target: { type: "service_account", id: instance.service_account_id },
|
|
791
|
+
metadata: { appId: instance.app_id, serviceAccountId: instance.service_account_id, status: "disabled" },
|
|
792
|
+
},
|
|
793
|
+
db,
|
|
794
|
+
);
|
|
795
|
+
const [current] = await db<{ credential_id: string; previous_credential_id: string | null }[]>`
|
|
796
|
+
SELECT credential_id, previous_credential_id FROM auth.app_workload_instances
|
|
797
|
+
WHERE app_id = ${instance.app_id} AND instance_id = ${instance.instance_id}
|
|
798
|
+
`;
|
|
799
|
+
if (current) {
|
|
800
|
+
for (const credentialId of [current.credential_id, current.previous_credential_id]) {
|
|
801
|
+
if (!credentialId) continue;
|
|
802
|
+
const result = await run(db, credentialId);
|
|
803
|
+
if (!result.ok) return result;
|
|
804
|
+
}
|
|
805
|
+
return ok();
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return run(db);
|
|
810
|
+
};
|
|
811
|
+
return options.db ? revokeWithWorkload(options.db) : sql.begin(revokeWithWorkload);
|
|
770
812
|
};
|
|
771
813
|
|
|
772
814
|
const findActiveByTokenPrefix = async (tokenPrefix: string): Promise<DbCredentialWithSecretRow | null> => {
|
|
@@ -27,7 +27,7 @@ import { deleteKey, readKey, readKeyState, type StoredSettingState, writeKey } f
|
|
|
27
27
|
*/
|
|
28
28
|
export interface SettingsAPI<F extends Record<string, unknown>> {
|
|
29
29
|
get<K extends keyof F & string>(key: K): Promise<F[K]>;
|
|
30
|
-
read<K extends keyof F & string>(key: K): Promise<{ value: F[K]; stored: StoredSettingState }>;
|
|
30
|
+
read<K extends keyof F & string>(key: K): Promise<{ value: F[K]; stored: StoredSettingState; bindingMismatch?: true }>;
|
|
31
31
|
set<K extends keyof F & string>(key: K, value: F[K], options?: { replaceUnreadable?: boolean }): Promise<void>;
|
|
32
32
|
remove<K extends keyof F & string>(key: K): Promise<void>;
|
|
33
33
|
}
|
|
@@ -59,8 +59,8 @@ export const createSettingsAPI = <S extends AppSettingsMap>(): SettingsAPI<{
|
|
|
59
59
|
*/
|
|
60
60
|
export const coreSettings = {
|
|
61
61
|
get: <T = unknown>(key: string): Promise<T> => readKey(key) as Promise<T>,
|
|
62
|
-
read: <T = unknown>(key: string): Promise<{ value: T; stored: StoredSettingState }> =>
|
|
63
|
-
readKeyState(key) as Promise<{ value: T; stored: StoredSettingState }>,
|
|
62
|
+
read: <T = unknown>(key: string): Promise<{ value: T; stored: StoredSettingState; bindingMismatch?: true }> =>
|
|
63
|
+
readKeyState(key) as Promise<{ value: T; stored: StoredSettingState; bindingMismatch?: true }>,
|
|
64
64
|
set: (key: string, value: unknown, options?: { replaceUnreadable?: boolean }): Promise<void> => writeKey(key, value, undefined, options),
|
|
65
65
|
remove: (key: string): Promise<void> => deleteKey(key),
|
|
66
66
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { SETTINGS, SETTINGS_MAP } from "./defaults";
|
|
4
|
+
|
|
5
|
+
const envelope = z.object({ value: z.unknown(), bindings: z.record(z.string(), z.string()) });
|
|
6
|
+
const hash = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
7
|
+
export const settingOwners = (key: string) => SETTINGS.filter((def) => def.resetOnChange?.includes(key));
|
|
8
|
+
|
|
9
|
+
/** Bind the value inside its encrypted row; no independently writable marker. */
|
|
10
|
+
export const bindSettingValue = async (key: string, value: unknown, resolve: (key: string) => Promise<unknown>): Promise<unknown> => {
|
|
11
|
+
const owners = settingOwners(key);
|
|
12
|
+
if (!owners.length) return value;
|
|
13
|
+
const bindings: Record<string, string> = {};
|
|
14
|
+
for (const owner of owners) bindings[owner.key] = hash(await resolve(owner.key));
|
|
15
|
+
return { value, bindings };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const unwrapSettingValue = (key: string, raw: unknown): unknown => {
|
|
19
|
+
if (!settingOwners(key).length) return raw;
|
|
20
|
+
const parsed = envelope.safeParse(raw);
|
|
21
|
+
return parsed.success ? parsed.data.value : raw;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const replaceSettingValue = (key: string, raw: unknown, value: unknown): unknown => {
|
|
25
|
+
if (!settingOwners(key).length) return value;
|
|
26
|
+
const parsed = envelope.safeParse(raw);
|
|
27
|
+
return parsed.success ? { ...parsed.data, value } : value;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Missing or stale bindings require a new value, including legacy unbound rows. */
|
|
31
|
+
export const resolveBoundSetting = async (
|
|
32
|
+
key: string,
|
|
33
|
+
raw: unknown,
|
|
34
|
+
resolve: (key: string) => Promise<unknown>,
|
|
35
|
+
): Promise<{ value: unknown; mismatch: boolean }> => {
|
|
36
|
+
const owners = settingOwners(key);
|
|
37
|
+
const fallback = SETTINGS_MAP.get(key)?.default;
|
|
38
|
+
if (!owners.length) return { value: raw, mismatch: false };
|
|
39
|
+
const parsed = envelope.safeParse(raw);
|
|
40
|
+
if (!parsed.success) return { value: fallback, mismatch: true };
|
|
41
|
+
for (const owner of owners) {
|
|
42
|
+
if (parsed.data.bindings[owner.key] !== hash(await resolve(owner.key))) return { value: fallback, mismatch: true };
|
|
43
|
+
}
|
|
44
|
+
return { value: parsed.data.value, mismatch: false };
|
|
45
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { env } from "../../config/env";
|
|
2
|
+
import { DEFAULT_WORKFLOW_AI_BUDGET, validateWorkflowAiBudget } from "../../config/workflow-ai";
|
|
2
3
|
import { GEO_MAX_CREDITS, GEO_MAX_RPS } from "../../contracts/geo-admin";
|
|
4
|
+
import { DEFAULT_TRUSTED_PROXIES, validateNetworkAllowlist, validateTrustedProxies } from "./validation";
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Platform-owned settings, declared in the `defineApp({ settings: ... })` shape.
|
|
@@ -30,6 +32,121 @@ const IPA_ACCOUNT_TRANSITION_OPTIONS = [
|
|
|
30
32
|
] as const;
|
|
31
33
|
|
|
32
34
|
export const CORE_SETTINGS = {
|
|
35
|
+
"outgoing_mail.allowed_targets": {
|
|
36
|
+
kind: "string_list",
|
|
37
|
+
default: [],
|
|
38
|
+
label: "Outgoing mail allowed targets",
|
|
39
|
+
description: "Exact internal SMTP and IMAP hosts, IPs or CIDRs. Changes apply to new connections.",
|
|
40
|
+
envVariable: "OUTGOING_MAIL_ALLOWED_TARGETS",
|
|
41
|
+
envBootstrap: () => env.$readPresent("OUTGOING_MAIL_ALLOWED_TARGETS"),
|
|
42
|
+
validate: validateNetworkAllowlist,
|
|
43
|
+
presentation: {
|
|
44
|
+
translations: {
|
|
45
|
+
de: {
|
|
46
|
+
label: "Erlaubte Ziele für ausgehende E-Mails",
|
|
47
|
+
description: "Exakte interne SMTP- und IMAP-Hosts, IPs oder CIDRs. Änderungen gelten für neue Verbindungen.",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
"gateway.ingress_cert_resolver": {
|
|
53
|
+
kind: "string",
|
|
54
|
+
default: "le",
|
|
55
|
+
label: "App domain certificate resolver",
|
|
56
|
+
description:
|
|
57
|
+
"Traefik certificate resolver used by app domain routers. Must name a configured ACME resolver; changes apply within five seconds.",
|
|
58
|
+
validate: (value: unknown) =>
|
|
59
|
+
typeof value === "string" && /^[a-zA-Z0-9_-]+$/.test(value)
|
|
60
|
+
? undefined
|
|
61
|
+
: "Use a Traefik certificate resolver name (letters, digits, underscores or hyphens).",
|
|
62
|
+
presentation: {
|
|
63
|
+
translations: {
|
|
64
|
+
de: {
|
|
65
|
+
label: "Zertifikatsresolver für App-Domains",
|
|
66
|
+
description:
|
|
67
|
+
"Traefik-Zertifikatsresolver der App-Domains. Muss einen konfigurierten ACME-Resolver benennen; Änderungen wirken innerhalb von fünf Sekunden.",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
"gateway.trusted_proxies": {
|
|
73
|
+
kind: "string_list",
|
|
74
|
+
default: DEFAULT_TRUSTED_PROXIES,
|
|
75
|
+
label: "Trusted gateway proxies",
|
|
76
|
+
description: "IP addresses or CIDRs allowed to supply forwarded client addresses. Changes take effect after gateway restart.",
|
|
77
|
+
envVariable: "GATEWAY_TRUSTED_PROXIES",
|
|
78
|
+
envBootstrap: () => env.$readPresent("GATEWAY_TRUSTED_PROXIES"),
|
|
79
|
+
validate: validateTrustedProxies,
|
|
80
|
+
presentation: {
|
|
81
|
+
translations: {
|
|
82
|
+
de: {
|
|
83
|
+
label: "Vertrauenswürdige Gateway-Proxys",
|
|
84
|
+
description:
|
|
85
|
+
"IP-Adressen oder CIDRs, die weitergeleitete Client-Adressen liefern dürfen. Änderungen wirken nach einem Gateway-Neustart.",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
"ai.workflow_budget": {
|
|
91
|
+
kind: "text",
|
|
92
|
+
default: JSON.stringify(DEFAULT_WORKFLOW_AI_BUDGET),
|
|
93
|
+
label: "Workflow AI budget",
|
|
94
|
+
description:
|
|
95
|
+
"JSON limits for source, rate and concurrency admission. Changes apply to new work; globalConcurrent also sets worker capacity and requires restarting all AI workers.",
|
|
96
|
+
envVariable: "WORKFLOW_AI_BUDGET",
|
|
97
|
+
envBootstrap: () => {
|
|
98
|
+
const value = env.$readPresent("WORKFLOW_AI_BUDGET");
|
|
99
|
+
return value === undefined ? undefined : JSON.stringify(value);
|
|
100
|
+
},
|
|
101
|
+
validate: validateWorkflowAiBudget,
|
|
102
|
+
presentation: {
|
|
103
|
+
translations: {
|
|
104
|
+
de: {
|
|
105
|
+
label: "KI-Workflow-Budget",
|
|
106
|
+
description:
|
|
107
|
+
"JSON-Grenzen für Quellen, Rate und gleichzeitige Arbeit. Änderungen gelten für neue Arbeit; globalConcurrent bestimmt auch die Worker-Kapazität und erfordert den Neustart aller KI-Worker.",
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
"ai.workflow_attempt_timeout_ms": {
|
|
113
|
+
kind: "number",
|
|
114
|
+
default: 60000,
|
|
115
|
+
label: "Workflow AI attempt timeout (ms)",
|
|
116
|
+
description: "Absolute attempt deadline including admission and repair. Changes apply to new attempts.",
|
|
117
|
+
envVariable: "WORKFLOW_AI_ATTEMPT_TIMEOUT_MS",
|
|
118
|
+
envBootstrap: () => env.$readPresent("WORKFLOW_AI_ATTEMPT_TIMEOUT_MS"),
|
|
119
|
+
integer: true,
|
|
120
|
+
min: 1,
|
|
121
|
+
presentation: {
|
|
122
|
+
translations: {
|
|
123
|
+
de: {
|
|
124
|
+
label: "Zeitlimit je KI-Workflow-Versuch (ms)",
|
|
125
|
+
description: "Absolute Frist einschließlich Zulassung und Reparatur. Änderungen gelten für neue Versuche.",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
"ai.workflow_queue_timeout_ms": {
|
|
131
|
+
kind: "number",
|
|
132
|
+
default: 60000,
|
|
133
|
+
label: "Workflow AI queue timeout (ms)",
|
|
134
|
+
description: "Maximum queue wait before visible failure. Changes apply to new requests and the next recovery scan.",
|
|
135
|
+
envVariable: "WORKFLOW_AI_QUEUE_TIMEOUT_MS",
|
|
136
|
+
envBootstrap: () => env.$readPresent("WORKFLOW_AI_QUEUE_TIMEOUT_MS"),
|
|
137
|
+
integer: true,
|
|
138
|
+
min: 1,
|
|
139
|
+
presentation: {
|
|
140
|
+
translations: {
|
|
141
|
+
de: {
|
|
142
|
+
label: "Wartezeitlimit für KI-Workflows (ms)",
|
|
143
|
+
description:
|
|
144
|
+
"Maximale Wartezeit bis zum sichtbaren Fehler. Änderungen gelten für neue Anfragen und den nächsten Wiederherstellungslauf.",
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
|
|
33
150
|
"storage.endpoint": {
|
|
34
151
|
kind: "url",
|
|
35
152
|
label: "Garage S3 endpoint",
|
|
@@ -228,6 +345,7 @@ export const CORE_SETTINGS = {
|
|
|
228
345
|
placeholder: "e.g. https://cloud.example.org",
|
|
229
346
|
envFallback: () => env.APP_URL,
|
|
230
347
|
envBootstrap: () => env.APP_URL,
|
|
348
|
+
envVariable: "APP_URL",
|
|
231
349
|
},
|
|
232
350
|
"app.home_path": {
|
|
233
351
|
kind: "string",
|
|
@@ -494,8 +612,8 @@ export const CORE_SETTINGS = {
|
|
|
494
612
|
default: "",
|
|
495
613
|
description: "Internal base URL of the Gotenberg service used for HTML-to-PDF rendering.",
|
|
496
614
|
placeholder: "e.g. http://gotenberg:3000",
|
|
497
|
-
envFallback: () => env.GOTENBERG_URL,
|
|
498
615
|
envBootstrap: () => env.GOTENBERG_URL,
|
|
616
|
+
envVariable: "GOTENBERG_URL",
|
|
499
617
|
},
|
|
500
618
|
"gotenberg.username": {
|
|
501
619
|
kind: "string",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { connections } from "../../runtime/connections";
|
|
1
2
|
/**
|
|
2
3
|
* Encryption helpers for settings at-rest.
|
|
3
4
|
*
|
|
@@ -7,7 +8,6 @@
|
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { createHash } from "node:crypto";
|
|
10
|
-
import { env } from "../../config/env";
|
|
11
11
|
import { crypto } from "../../server/services";
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -34,7 +34,7 @@ import { crypto } from "../../server/services";
|
|
|
34
34
|
* with deployments that have always used hex-format APP_SECRET.
|
|
35
35
|
*/
|
|
36
36
|
export const getAppSecret = (): string => {
|
|
37
|
-
const raw =
|
|
37
|
+
const raw = connections().appSecret.current.trim();
|
|
38
38
|
if (!raw) {
|
|
39
39
|
throw new Error("APP_SECRET is required to read or write encrypted settings");
|
|
40
40
|
}
|
|
@@ -32,6 +32,12 @@ type SettingCommon = {
|
|
|
32
32
|
group: string;
|
|
33
33
|
envFallback?: SettingEnvResolver;
|
|
34
34
|
envBootstrap?: SettingEnvResolver;
|
|
35
|
+
/** Environment name for migration diagnostics; values are never returned. */
|
|
36
|
+
envVariable?: string;
|
|
37
|
+
/** Discard these stored settings when this normalized value changes. */
|
|
38
|
+
resetOnChange?: readonly string[];
|
|
39
|
+
/** Additional server-side validation after kind normalization. */
|
|
40
|
+
validate?: (value: unknown) => string | undefined;
|
|
35
41
|
};
|
|
36
42
|
|
|
37
43
|
type SettingStringLikeKind = "string" | "text" | "email" | "url" | "secret" | "image" | "cron" | "timezone" | "template";
|
|
@@ -118,6 +124,9 @@ export const toLegacySettingDefs = (settings: Record<string, unknown>, requested
|
|
|
118
124
|
presentation: compilePresentation(d.presentation, baseLocale, key),
|
|
119
125
|
envFallback: d.envFallback as (() => unknown) | undefined,
|
|
120
126
|
envBootstrap: d.envBootstrap as (() => unknown) | undefined,
|
|
127
|
+
envVariable: d.envVariable as string | undefined,
|
|
128
|
+
validate: d.validate as SettingDef["validate"],
|
|
129
|
+
resetOnChange: d.resetOnChange as readonly string[] | undefined,
|
|
121
130
|
templateVars: d.templateVars as readonly string[] | undefined,
|
|
122
131
|
options: d.options as ReadonlyArray<{ value: string; label: string }> | undefined,
|
|
123
132
|
min: d.min as number | undefined,
|
|
@@ -262,7 +271,11 @@ export const normalizeSettingValue = (def: SettingDef, raw: unknown): unknown =>
|
|
|
262
271
|
};
|
|
263
272
|
|
|
264
273
|
export const validateSettingValue = (def: SettingDef, raw: unknown): SettingValidationResult => {
|
|
274
|
+
if (def.validate && def.kind === "string_list" && Array.isArray(raw) && raw.some((entry) => typeof entry !== "string"))
|
|
275
|
+
return { ok: false, error: `${getSettingLabel(def)} must be a list of strings` };
|
|
265
276
|
const value = normalizeSettingValue(def, raw);
|
|
277
|
+
const error = def.validate?.(value);
|
|
278
|
+
if (error) return { ok: false, error };
|
|
266
279
|
|
|
267
280
|
if (def.key === "app.documentation_url") {
|
|
268
281
|
try {
|
|
@@ -356,7 +369,7 @@ export const validateSettingValue = (def: SettingDef, raw: unknown): SettingVali
|
|
|
356
369
|
export const SETTINGS_MAP = new Map(SETTINGS.map((setting) => [setting.key, setting] as const));
|
|
357
370
|
|
|
358
371
|
/** Fields whose divergence changes behaviour rather than just wording. */
|
|
359
|
-
const CONFLICT_FIELDS = ["kind", "default", "min", "max"] as const;
|
|
372
|
+
const CONFLICT_FIELDS = ["kind", "default", "min", "max", "resetOnChange"] as const;
|
|
360
373
|
|
|
361
374
|
export function registerSettings(defs: SettingDef[]): void {
|
|
362
375
|
for (const def of defs) {
|
|
@@ -368,7 +381,8 @@ export function registerSettings(defs: SettingDef[]): void {
|
|
|
368
381
|
// the winner would then depend on which container loaded which module.
|
|
369
382
|
const existing = SETTINGS[existingIndex] as Record<string, unknown>;
|
|
370
383
|
const incoming = def as unknown as Record<string, unknown>;
|
|
371
|
-
const conflicts = CONFLICT_FIELDS.filter((field) => JSON.stringify(existing[field]) !== JSON.stringify(incoming[field]));
|
|
384
|
+
const conflicts: string[] = CONFLICT_FIELDS.filter((field) => JSON.stringify(existing[field]) !== JSON.stringify(incoming[field]));
|
|
385
|
+
if (existing.validate !== incoming.validate) conflicts.push("validate");
|
|
372
386
|
if (conflicts.length > 0) {
|
|
373
387
|
throw new Error(
|
|
374
388
|
`Setting "${def.key}" is registered twice with different ${conflicts.join(", ")}. ` +
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { EnvSettingStatus } from "../../contracts/settings-env";
|
|
2
|
+
import { SETTINGS, type SettingDef, validateSettingValue } from "./defaults";
|
|
3
|
+
import { readStates, type SettingRead } from "./store";
|
|
4
|
+
|
|
5
|
+
const equal = (a: unknown, b: unknown, jsonText = false): boolean => {
|
|
6
|
+
if (jsonText && typeof a === "string" && typeof b === "string") {
|
|
7
|
+
try {
|
|
8
|
+
return equal(JSON.parse(a), JSON.parse(b));
|
|
9
|
+
} catch {
|
|
10
|
+
return a === b;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => equal(v, b[i]));
|
|
14
|
+
if (a && b && typeof a === "object" && typeof b === "object") {
|
|
15
|
+
const left = Object.entries(a),
|
|
16
|
+
right = new Map(Object.entries(b));
|
|
17
|
+
return left.length === right.size && left.every(([k, v]) => right.has(k) && equal(v, right.get(k)));
|
|
18
|
+
}
|
|
19
|
+
return a === b;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Compare locally, return presence and state only, including for secret settings. */
|
|
23
|
+
export const envSettingStatus = (def: SettingDef, stored: SettingRead): EnvSettingStatus => {
|
|
24
|
+
let envValue: unknown,
|
|
25
|
+
invalidEnvValue = false,
|
|
26
|
+
envValuePresent = false;
|
|
27
|
+
try {
|
|
28
|
+
const raw = (def.envBootstrap ?? def.envFallback)?.();
|
|
29
|
+
envValuePresent = raw !== undefined;
|
|
30
|
+
if (envValuePresent) {
|
|
31
|
+
const validated = validateSettingValue(def, raw);
|
|
32
|
+
if (validated.ok) envValue = validated.value;
|
|
33
|
+
else invalidEnvValue = true;
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
envValuePresent = true;
|
|
37
|
+
invalidEnvValue = true;
|
|
38
|
+
}
|
|
39
|
+
const unreadableStoredValue = stored.stored === "unreadable";
|
|
40
|
+
const storedValuePresent = stored.stored !== "none";
|
|
41
|
+
const conflict =
|
|
42
|
+
envValuePresent && storedValuePresent
|
|
43
|
+
? invalidEnvValue || unreadableStoredValue
|
|
44
|
+
? "unknown"
|
|
45
|
+
: equal(envValue, stored.value, def.key === "ai.workflow_budget")
|
|
46
|
+
? "none"
|
|
47
|
+
: "differs"
|
|
48
|
+
: "none";
|
|
49
|
+
return {
|
|
50
|
+
key: def.key,
|
|
51
|
+
envVariable: def.envVariable ?? null,
|
|
52
|
+
storedValuePresent,
|
|
53
|
+
envValuePresent,
|
|
54
|
+
conflict,
|
|
55
|
+
unreadableStoredValue,
|
|
56
|
+
invalidEnvValue,
|
|
57
|
+
needsAttention: invalidEnvValue || unreadableStoredValue || conflict !== "none" || (envValuePresent && !storedValuePresent),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const readEnvSettingsStatus = async (): Promise<EnvSettingStatus[]> => {
|
|
62
|
+
const defs = SETTINGS.filter((def) => def.envBootstrap || def.envFallback);
|
|
63
|
+
const states = await readStates(defs.map((def) => def.key));
|
|
64
|
+
return defs.map((def) => {
|
|
65
|
+
const state = states.get(def.key);
|
|
66
|
+
if (!state) throw new Error(`Missing stored state for ${def.key}`);
|
|
67
|
+
return envSettingStatus(def, state);
|
|
68
|
+
});
|
|
69
|
+
};
|
|
@@ -11,14 +11,24 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { redis, sql } from "bun";
|
|
14
|
+
import { bindSettingValue, replaceSettingValue, unwrapSettingValue } from "./binding";
|
|
14
15
|
import { decryptValue, encryptValue, getAppSecret } from "./crypto";
|
|
15
16
|
import { resolveSettingPresentation, SETTINGS, SETTINGS_MAP, type SettingDef, validateSettingValue } from "./defaults";
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
deleteKey,
|
|
19
|
+
lockSettingChanges,
|
|
20
|
+
readKey,
|
|
21
|
+
readKeyState,
|
|
22
|
+
readStates,
|
|
23
|
+
resetChangedSettings,
|
|
24
|
+
type SettingRead,
|
|
25
|
+
writeKey,
|
|
26
|
+
} from "./store";
|
|
17
27
|
|
|
18
28
|
type SqlClient = typeof sql;
|
|
19
29
|
|
|
20
30
|
type StoredRow = { key: string; value: string };
|
|
21
|
-
type PendingRow = { value: unknown; originalCiphertext: string };
|
|
31
|
+
type PendingRow = { value: unknown; originalValue: unknown; originalCiphertext: string };
|
|
22
32
|
type NormalizationStats = {
|
|
23
33
|
encryptedLoaded: number;
|
|
24
34
|
normalizedUpdated: number;
|
|
@@ -32,7 +42,13 @@ const REDIS_KEY = (key: string) => `settings:${key}`;
|
|
|
32
42
|
const isEqual = (left: unknown, right: unknown): boolean => JSON.stringify(left) === JSON.stringify(right);
|
|
33
43
|
|
|
34
44
|
const resolveEnvValue = (def: SettingDef | undefined, mode: "fallback" | "bootstrap"): unknown => {
|
|
35
|
-
|
|
45
|
+
let raw: unknown;
|
|
46
|
+
try {
|
|
47
|
+
raw = mode === "fallback" ? def?.envFallback?.() : def?.envBootstrap?.();
|
|
48
|
+
} catch {
|
|
49
|
+
console.warn(`[settings] ignoring invalid ${mode} value for "${def?.key}"`);
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
36
52
|
if (!def || raw === undefined) return undefined;
|
|
37
53
|
|
|
38
54
|
const validated = validateSettingValue(def, raw);
|
|
@@ -54,7 +70,7 @@ const shouldBootstrapValue = (def: SettingDef, value: unknown): boolean => {
|
|
|
54
70
|
return typeof value === "number" && Number.isFinite(value);
|
|
55
71
|
case "string_list":
|
|
56
72
|
case "number_list":
|
|
57
|
-
return Array.isArray(value) && value.length > 0;
|
|
73
|
+
return Array.isArray(value) && (value.length > 0 || def.envVariable !== undefined);
|
|
58
74
|
default:
|
|
59
75
|
return typeof value === "string" && value.trim().length > 0;
|
|
60
76
|
}
|
|
@@ -79,7 +95,7 @@ const normalizeStoredEntries = async (): Promise<{
|
|
|
79
95
|
for (const row of rows) {
|
|
80
96
|
try {
|
|
81
97
|
const value = await decryptValue(row.value);
|
|
82
|
-
pendingRows.set(row.key, { value, originalCiphertext: row.value });
|
|
98
|
+
pendingRows.set(row.key, { value: unwrapSettingValue(row.key, value), originalValue: value, originalCiphertext: row.value });
|
|
83
99
|
stats.encryptedLoaded += 1;
|
|
84
100
|
} catch {
|
|
85
101
|
// An unreadable value is not a missing value (#1295). Usually this
|
|
@@ -113,7 +129,7 @@ const normalizeStoredEntries = async (): Promise<{
|
|
|
113
129
|
normalized.push({ key, value: validated.value });
|
|
114
130
|
|
|
115
131
|
if (!isEqual(row.value, validated.value)) {
|
|
116
|
-
const encryptedValue = await encryptValue(validated.value);
|
|
132
|
+
const encryptedValue = await encryptValue(replaceSettingValue(key, row.originalValue, validated.value));
|
|
117
133
|
const updated = await sql<{ key: string }[]>`
|
|
118
134
|
UPDATE settings.entries SET value = ${encryptedValue}, updated_at = now()
|
|
119
135
|
WHERE key = ${key} AND value = ${row.originalCiphertext}
|
|
@@ -136,26 +152,53 @@ const bootstrapEnvBackedEntries = async (config: {
|
|
|
136
152
|
}): Promise<Array<{ key: string; value: unknown }>> => {
|
|
137
153
|
const rowsByKey = new Map(config.rows.map((row) => [row.key, row.value]));
|
|
138
154
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
const imported = new Set<string>();
|
|
156
|
+
await sql.begin(async (tx) => {
|
|
157
|
+
// Import access before its endpoint regardless of declaration order.
|
|
158
|
+
const definitions = [...SETTINGS].sort((a, b) => Number(Boolean(a.resetOnChange?.length)) - Number(Boolean(b.resetOnChange?.length)));
|
|
159
|
+
for (const def of definitions) {
|
|
160
|
+
// Existing rows block bootstrap even if unreadable, unknown, or invalid.
|
|
161
|
+
if (config.storedKeys.has(def.key)) continue;
|
|
162
|
+
let envValue = resolveEnvValue(def, "bootstrap");
|
|
163
|
+
if (!shouldBootstrapValue(def, envValue)) continue;
|
|
164
|
+
await lockSettingChanges(tx, def.key);
|
|
165
|
+
let bindingRefused = false;
|
|
166
|
+
// Do not import access from an environment that names other endpoints.
|
|
167
|
+
for (const owner of SETTINGS.filter((owner) => owner.resetOnChange?.includes(def.key))) {
|
|
168
|
+
const current = await readKeyState(owner.key, tx);
|
|
169
|
+
const expected = resolveEnvValue(owner, "bootstrap") ?? resolveEnvValue(owner, "fallback") ?? owner.default;
|
|
170
|
+
if (current.stored !== "none" && (current.stored === "unreadable" || !isEqual(current.value, expected))) {
|
|
171
|
+
envValue = def.default;
|
|
172
|
+
bindingRefused = true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const prior = def.resetOnChange?.length ? await readKeyState(def.key, tx) : undefined;
|
|
176
|
+
const encryptedValue = await encryptValue(
|
|
177
|
+
bindingRefused
|
|
178
|
+
? envValue
|
|
179
|
+
: await bindSettingValue(def.key, envValue, async (owner) => {
|
|
180
|
+
const current = await readKeyState(owner, tx);
|
|
181
|
+
const ownerDef = SETTINGS_MAP.get(owner);
|
|
182
|
+
if (current.stored === "unreadable") throw new Error(`Cannot bind setting to unreadable ${owner}`);
|
|
183
|
+
return current.stored === "none" ? (resolveEnvValue(ownerDef, "bootstrap") ?? current.value) : current.value;
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
const inserted = await tx<{ key: string }[]>`
|
|
187
|
+
INSERT INTO settings.entries (key, value, updated_at)
|
|
188
|
+
VALUES (${def.key}, ${encryptedValue}, now())
|
|
189
|
+
ON CONFLICT (key) DO NOTHING
|
|
190
|
+
RETURNING key
|
|
191
|
+
`;
|
|
192
|
+
if (inserted.length === 0) continue;
|
|
193
|
+
// A newly bootstrapped endpoint must not inherit previously stored access.
|
|
194
|
+
// Values imported together belong to the same environment configuration.
|
|
195
|
+
await resetChangedSettings(tx, def.key, envValue, imported, prior);
|
|
196
|
+
imported.add(def.key);
|
|
197
|
+
rowsByKey.set(def.key, envValue);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
await Promise.all([...imported].map((key) => redis.del(REDIS_KEY(key), ...(SETTINGS_MAP.get(key)?.resetOnChange ?? []).map(REDIS_KEY))));
|
|
201
|
+
config.stats.envBootstrapped += imported.size;
|
|
159
202
|
|
|
160
203
|
return [...rowsByKey.entries()].map(([key, value]) => ({ key, value }));
|
|
161
204
|
};
|