@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.
Files changed (104) hide show
  1. package/package.json +8 -4
  2. package/scripts/build.ts +5 -1
  3. package/scripts/preload.ts +5 -1
  4. package/src/_internal/app-access-manager-sql.ts +10 -0
  5. package/src/_internal/bounded-json.ts +7 -1
  6. package/src/_internal/capabilities.ts +8 -0
  7. package/src/_internal/define-app.ts +69 -2
  8. package/src/_internal/heartbeat.ts +21 -0
  9. package/src/_internal/nats-connection.ts +10 -9
  10. package/src/_internal/postgres-application-name.ts +11 -0
  11. package/src/_internal/process-sync.ts +15 -6
  12. package/src/_internal/public-surface.ts +76 -0
  13. package/src/_internal/purpose-name.ts +7 -0
  14. package/src/_internal/readiness.ts +6 -1
  15. package/src/_internal/registry-validation.ts +1 -0
  16. package/src/_internal/registry.ts +49 -3
  17. package/src/_internal/resource-principals.ts +4 -34
  18. package/src/_internal/workload-credential.ts +154 -0
  19. package/src/_internal/workload-protocol.ts +36 -0
  20. package/src/ai/application-tasks.ts +5 -2
  21. package/src/ai/chart-block.ts +36 -711
  22. package/src/ai/default-tool-contracts.ts +21 -259
  23. package/src/api/admin-app-domains.ts +62 -0
  24. package/src/api/admin-app-keys.ts +66 -0
  25. package/src/api/admin-core-settings.ts +2 -0
  26. package/src/api/admin-identity.ts +27 -1
  27. package/src/api/admin-settings-env.ts +120 -0
  28. package/src/api/app-bootstrap.ts +131 -0
  29. package/src/api/capabilities.ts +17 -4
  30. package/src/api/index.ts +8 -0
  31. package/src/api/settings-ops.ts +35 -0
  32. package/src/app-key.ts +125 -0
  33. package/src/browser/CloudResourceSearch.tsx +48 -41
  34. package/src/capabilities/discussion-providers.ts +3 -2
  35. package/src/capabilities/resource-readers.ts +13 -2
  36. package/src/capabilities/response.ts +6 -2
  37. package/src/capabilities/server-invoke.ts +73 -29
  38. package/src/capabilities/server.ts +1 -1
  39. package/src/capabilities/types.ts +1 -1
  40. package/src/cli/admin/app-credentials.ts +32 -7
  41. package/src/cli/admin/app-domains.ts +140 -0
  42. package/src/cli/admin/app-keys.ts +226 -0
  43. package/src/cli/admin/index.ts +109 -84
  44. package/src/cli/admin/settings.ts +62 -0
  45. package/src/config/define-env.ts +63 -8
  46. package/src/config/env.ts +34 -22
  47. package/src/config/index.ts +12 -1
  48. package/src/config/workflow-ai.ts +28 -0
  49. package/src/contracts/app-domains.ts +91 -0
  50. package/src/contracts/app-status.ts +104 -0
  51. package/src/contracts/capabilities.ts +22 -0
  52. package/src/contracts/index.ts +1 -0
  53. package/src/contracts/registry.ts +2 -0
  54. package/src/contracts/settings-env.ts +21 -0
  55. package/src/contracts/settings-types.ts +6 -0
  56. package/src/index.ts +10 -0
  57. package/src/runtime/bootstrap-client.ts +195 -0
  58. package/src/runtime/bootstrap-protocol.ts +60 -0
  59. package/src/runtime/clients.ts +72 -0
  60. package/src/runtime/connections.ts +46 -0
  61. package/src/runtime/resolve-connections.ts +91 -0
  62. package/src/server/app-key.ts +14 -0
  63. package/src/server/client-ip.ts +1 -1
  64. package/src/server/index.ts +1 -0
  65. package/src/services/app-access-management.ts +1 -8
  66. package/src/services/app-admin-facts.ts +160 -0
  67. package/src/services/app-bootstrap.ts +134 -0
  68. package/src/services/app-domain-check.ts +152 -0
  69. package/src/services/app-domains.ts +172 -0
  70. package/src/services/app-keys.ts +301 -0
  71. package/src/services/downstream-budget.ts +2 -2
  72. package/src/services/gateway.ts +6 -0
  73. package/src/services/identity/index.ts +3 -0
  74. package/src/services/identity/resource-readers.ts +80 -0
  75. package/src/services/identity/runtime-config.ts +14 -6
  76. package/src/services/identity/workload-runtime.ts +253 -0
  77. package/src/services/index.ts +8 -1
  78. package/src/services/links/index.ts +16 -0
  79. package/src/services/links/runtime.ts +44 -0
  80. package/src/services/links/store.ts +105 -0
  81. package/src/services/logging/index.ts +7 -5
  82. package/src/services/logging/redaction.ts +6 -1
  83. package/src/services/logging/trace.ts +8 -8
  84. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  85. package/src/services/request-cache-redis.ts +2 -2
  86. package/src/services/service-account-credentials.ts +63 -21
  87. package/src/services/settings/api.ts +3 -3
  88. package/src/services/settings/binding.ts +45 -0
  89. package/src/services/settings/core-settings.ts +119 -1
  90. package/src/services/settings/crypto.ts +2 -2
  91. package/src/services/settings/defaults.ts +16 -2
  92. package/src/services/settings/env-status.ts +69 -0
  93. package/src/services/settings/index.ts +69 -26
  94. package/src/services/settings/store.ts +78 -8
  95. package/src/services/settings/validation.ts +40 -0
  96. package/src/shared/index.ts +11 -10
  97. package/src/shared/markdown/extensions/tables.ts +10 -1
  98. package/src/ssr/admin-navigation.ts +0 -1
  99. package/src/ssr/platform-messages.ts +0 -2
  100. package/src/start.ts +57 -0
  101. package/src/workflows/ai/index.ts +1 -0
  102. package/src/workflows/ai/runtime.ts +16 -8
  103. package/src/workflows/ai/settings.ts +5 -0
  104. 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 sql<{ exists: boolean }[]>`
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 (params: {
380
- serviceAccountId: string;
381
- name: string;
382
- createdBy?: string | null;
383
- expiresAt?: string | null;
384
- scopes?: string[];
385
- }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
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 = ${params.credentialId}::uuid
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 = ${params.credentialId}::uuid
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: params.credentialId, label: row?.name ?? existing?.name ?? null },
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
- return options.db ? run(options.db) : sql.begin(run);
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 = env.APP_SECRET.trim();
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 { deleteKey, readKey, readKeyState, readStates, type SettingRead, writeKey } from "./store";
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
- const raw = mode === "fallback" ? def?.envFallback?.() : def?.envBootstrap?.();
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
- for (const def of SETTINGS) {
140
- // Existing rows block bootstrap even if unreadable, unknown, or invalid.
141
- if (config.storedKeys.has(def.key)) continue;
142
-
143
- const envValue = resolveEnvValue(def, "bootstrap");
144
- if (!shouldBootstrapValue(def, envValue)) continue;
145
-
146
- const encryptedValue = await encryptValue(envValue);
147
- const inserted = await sql<{ key: string }[]>`
148
- INSERT INTO settings.entries (key, value, updated_at)
149
- VALUES (${def.key}, ${encryptedValue}, now())
150
- ON CONFLICT (key) DO NOTHING
151
- RETURNING key
152
- `;
153
- if (inserted.length === 0) continue;
154
-
155
- await redis.del(REDIS_KEY(def.key));
156
- rowsByKey.set(def.key, envValue);
157
- config.stats.envBootstrapped += 1;
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
  };