@k2b/cloud 0.46.0 → 0.47.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 (68) 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/define-app.ts +38 -0
  5. package/src/_internal/heartbeat.ts +19 -0
  6. package/src/_internal/postgres-application-name.ts +11 -0
  7. package/src/_internal/process-sync.ts +9 -0
  8. package/src/_internal/readiness.ts +6 -1
  9. package/src/_internal/resource-principals.ts +4 -34
  10. package/src/_internal/workload-credential.ts +153 -0
  11. package/src/_internal/workload-protocol.ts +36 -0
  12. package/src/ai/application-tasks.ts +5 -2
  13. package/src/ai/chart-block.ts +36 -711
  14. package/src/ai/default-tool-contracts.ts +21 -259
  15. package/src/api/admin-app-keys.ts +66 -0
  16. package/src/api/admin-core-settings.ts +2 -0
  17. package/src/api/admin-identity.ts +27 -1
  18. package/src/api/admin-settings-env.ts +120 -0
  19. package/src/api/capabilities.ts +9 -3
  20. package/src/api/index.ts +3 -0
  21. package/src/api/settings-ops.ts +35 -0
  22. package/src/app-key.ts +125 -0
  23. package/src/browser/CloudResourceSearch.tsx +48 -41
  24. package/src/capabilities/discussion-providers.ts +3 -2
  25. package/src/capabilities/resource-readers.ts +13 -2
  26. package/src/capabilities/response.ts +4 -1
  27. package/src/capabilities/server-invoke.ts +46 -28
  28. package/src/capabilities/server.ts +1 -1
  29. package/src/capabilities/types.ts +1 -1
  30. package/src/cli/admin/app-credentials.ts +32 -7
  31. package/src/cli/admin/app-keys.ts +226 -0
  32. package/src/cli/admin/index.ts +7 -1
  33. package/src/cli/admin/settings.ts +62 -0
  34. package/src/config/define-env.ts +63 -8
  35. package/src/config/env.ts +27 -21
  36. package/src/config/index.ts +12 -1
  37. package/src/config/workflow-ai.ts +28 -0
  38. package/src/contracts/settings-env.ts +21 -0
  39. package/src/contracts/settings-types.ts +6 -0
  40. package/src/index.ts +9 -0
  41. package/src/runtime/clients.ts +72 -0
  42. package/src/runtime/connections.ts +37 -0
  43. package/src/runtime/resolve-connections.ts +91 -0
  44. package/src/server/client-ip.ts +1 -1
  45. package/src/services/app-keys.ts +298 -0
  46. package/src/services/gateway.ts +6 -0
  47. package/src/services/identity/index.ts +3 -0
  48. package/src/services/identity/resource-readers.ts +80 -0
  49. package/src/services/identity/runtime-config.ts +14 -6
  50. package/src/services/identity/workload-runtime.ts +253 -0
  51. package/src/services/index.ts +4 -1
  52. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  53. package/src/services/service-account-credentials.ts +63 -21
  54. package/src/services/settings/api.ts +3 -3
  55. package/src/services/settings/binding.ts +45 -0
  56. package/src/services/settings/core-settings.ts +99 -1
  57. package/src/services/settings/defaults.ts +16 -2
  58. package/src/services/settings/env-status.ts +69 -0
  59. package/src/services/settings/index.ts +69 -26
  60. package/src/services/settings/store.ts +73 -8
  61. package/src/services/settings/validation.ts +40 -0
  62. package/src/shared/index.ts +11 -10
  63. package/src/shared/markdown/extensions/tables.ts +10 -1
  64. package/src/start.ts +36 -0
  65. package/src/workflows/ai/index.ts +1 -0
  66. package/src/workflows/ai/runtime.ts +16 -8
  67. package/src/workflows/ai/settings.ts +5 -0
  68. package/src/shared/markdown/formula.ts +0 -1283
@@ -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,101 @@ 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.trusted_proxies": {
53
+ kind: "string_list",
54
+ default: DEFAULT_TRUSTED_PROXIES,
55
+ label: "Trusted gateway proxies",
56
+ description: "IP addresses or CIDRs allowed to supply forwarded client addresses. Changes take effect after gateway restart.",
57
+ envVariable: "GATEWAY_TRUSTED_PROXIES",
58
+ envBootstrap: () => env.$readPresent("GATEWAY_TRUSTED_PROXIES"),
59
+ validate: validateTrustedProxies,
60
+ presentation: {
61
+ translations: {
62
+ de: {
63
+ label: "Vertrauenswürdige Gateway-Proxys",
64
+ description:
65
+ "IP-Adressen oder CIDRs, die weitergeleitete Client-Adressen liefern dürfen. Änderungen wirken nach einem Gateway-Neustart.",
66
+ },
67
+ },
68
+ },
69
+ },
70
+ "ai.workflow_budget": {
71
+ kind: "text",
72
+ default: JSON.stringify(DEFAULT_WORKFLOW_AI_BUDGET),
73
+ label: "Workflow AI budget",
74
+ description:
75
+ "JSON limits for source, rate and concurrency admission. Changes apply to new work; globalConcurrent also sets worker capacity and requires restarting all AI workers.",
76
+ envVariable: "WORKFLOW_AI_BUDGET",
77
+ envBootstrap: () => {
78
+ const value = env.$readPresent("WORKFLOW_AI_BUDGET");
79
+ return value === undefined ? undefined : JSON.stringify(value);
80
+ },
81
+ validate: validateWorkflowAiBudget,
82
+ presentation: {
83
+ translations: {
84
+ de: {
85
+ label: "KI-Workflow-Budget",
86
+ description:
87
+ "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.",
88
+ },
89
+ },
90
+ },
91
+ },
92
+ "ai.workflow_attempt_timeout_ms": {
93
+ kind: "number",
94
+ default: 60000,
95
+ label: "Workflow AI attempt timeout (ms)",
96
+ description: "Absolute attempt deadline including admission and repair. Changes apply to new attempts.",
97
+ envVariable: "WORKFLOW_AI_ATTEMPT_TIMEOUT_MS",
98
+ envBootstrap: () => env.$readPresent("WORKFLOW_AI_ATTEMPT_TIMEOUT_MS"),
99
+ integer: true,
100
+ min: 1,
101
+ presentation: {
102
+ translations: {
103
+ de: {
104
+ label: "Zeitlimit je KI-Workflow-Versuch (ms)",
105
+ description: "Absolute Frist einschließlich Zulassung und Reparatur. Änderungen gelten für neue Versuche.",
106
+ },
107
+ },
108
+ },
109
+ },
110
+ "ai.workflow_queue_timeout_ms": {
111
+ kind: "number",
112
+ default: 60000,
113
+ label: "Workflow AI queue timeout (ms)",
114
+ description: "Maximum queue wait before visible failure. Changes apply to new requests and the next recovery scan.",
115
+ envVariable: "WORKFLOW_AI_QUEUE_TIMEOUT_MS",
116
+ envBootstrap: () => env.$readPresent("WORKFLOW_AI_QUEUE_TIMEOUT_MS"),
117
+ integer: true,
118
+ min: 1,
119
+ presentation: {
120
+ translations: {
121
+ de: {
122
+ label: "Wartezeitlimit für KI-Workflows (ms)",
123
+ description:
124
+ "Maximale Wartezeit bis zum sichtbaren Fehler. Änderungen gelten für neue Anfragen und den nächsten Wiederherstellungslauf.",
125
+ },
126
+ },
127
+ },
128
+ },
129
+
33
130
  "storage.endpoint": {
34
131
  kind: "url",
35
132
  label: "Garage S3 endpoint",
@@ -228,6 +325,7 @@ export const CORE_SETTINGS = {
228
325
  placeholder: "e.g. https://cloud.example.org",
229
326
  envFallback: () => env.APP_URL,
230
327
  envBootstrap: () => env.APP_URL,
328
+ envVariable: "APP_URL",
231
329
  },
232
330
  "app.home_path": {
233
331
  kind: "string",
@@ -494,8 +592,8 @@ export const CORE_SETTINGS = {
494
592
  default: "",
495
593
  description: "Internal base URL of the Gotenberg service used for HTML-to-PDF rendering.",
496
594
  placeholder: "e.g. http://gotenberg:3000",
497
- envFallback: () => env.GOTENBERG_URL,
498
595
  envBootstrap: () => env.GOTENBERG_URL,
596
+ envVariable: "GOTENBERG_URL",
499
597
  },
500
598
  "gotenberg.username": {
501
599
  kind: "string",
@@ -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
  };
@@ -20,6 +20,7 @@ import { claimCacheFill, completeCacheFill, MISSING_SETTING } from "../cache-fil
20
20
  import { stripImageDataUrlMetadata } from "../image-metadata";
21
21
  import { toPgTextArray } from "../postgres";
22
22
  import { requestCacheRedis } from "../request-cache-redis";
23
+ import { bindSettingValue, resolveBoundSetting, settingOwners, unwrapSettingValue } from "./binding";
23
24
  import { decryptValue, encryptValue } from "./crypto";
24
25
  import { SETTINGS, SETTINGS_MAP, type SettingDef, validateSettingValue } from "./defaults";
25
26
  import { SettingUnreadableError } from "./errors";
@@ -32,7 +33,7 @@ const REDIS_KEY = (k: string) => `settings:${k}`;
32
33
  type SqlClient = typeof sql;
33
34
 
34
35
  export type StoredSettingState = "none" | "readable" | "unreadable";
35
- export type SettingRead = { value: unknown; stored: StoredSettingState };
36
+ export type SettingRead = { value: unknown; stored: StoredSettingState; bindingMismatch?: true };
36
37
 
37
38
  /**
38
39
  * Forget cached values so every container re-reads on next access.
@@ -43,7 +44,8 @@ export type SettingRead = { value: unknown; stored: StoredSettingState };
43
44
  * therefore invalidate after it commits.
44
45
  */
45
46
  export const invalidateSettingsCache = async (keys: readonly string[]): Promise<void> => {
46
- if (keys.length > 0) await (await requestCacheRedis()).del(...keys.map(REDIS_KEY));
47
+ if (keys.length > 0)
48
+ await (await requestCacheRedis()).del(...keys.flatMap((key) => [key, ...(SETTINGS_MAP.get(key)?.resetOnChange ?? [])]).map(REDIS_KEY));
47
49
  };
48
50
  const REDIS_TTL_SEC = 300;
49
51
 
@@ -87,6 +89,15 @@ export const readKeyState = async (key: string, db: SqlClient = sql): Promise<Se
87
89
  export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unknown>> => {
88
90
  const result = new Map<string, unknown>();
89
91
  if (keys.length === 0) return result;
92
+ // Bound access is read with its endpoint in one database snapshot. Never
93
+ // resolve it from an independently cached secret.
94
+ const boundKeys = keys.filter((key) => settingOwners(key).length > 0);
95
+ if (boundKeys.length) {
96
+ const bound = await readStates(boundKeys);
97
+ const other = await bulkRead(keys.filter((key) => !bound.has(key)));
98
+ for (const key of keys) result.set(key, bound.has(key) ? bound.get(key)!.value : other.get(key));
99
+ return result;
100
+ }
90
101
  let cached: Array<string | null>;
91
102
  try {
92
103
  cached = await (await requestCacheRedis()).mget(...keys.map(REDIS_KEY));
@@ -152,17 +163,29 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
152
163
  export const readStates = async (keys: readonly string[], db: SqlClient = sql): Promise<Map<string, SettingRead>> => {
153
164
  const result = new Map<string, SettingRead>();
154
165
  if (keys.length === 0) return result;
155
- const rows = await db<StoredRow[]>`SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray([...keys])}::text[])`;
166
+ const snapshotKeys = [...new Set(keys.flatMap((key) => [key, ...settingOwners(key).map((owner) => owner.key)]))];
167
+ const rows = await db<StoredRow[]>`SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray(snapshotKeys)}::text[])`;
156
168
  const byKey = new Map(rows.map((row) => [row.key, row]));
169
+ const resolve = async (key: string) => {
170
+ const row = byKey.get(key);
171
+ const def = SETTINGS_MAP.get(key);
172
+ if (!row) return resolveFallback(def);
173
+ const raw = await decryptValue(row.value);
174
+ const checked = def ? validateSettingValue(def, unwrapSettingValue(key, raw)) : { ok: true as const, value: raw };
175
+ if (!checked.ok) throw new SettingUnreadableError(key);
176
+ return checked.value;
177
+ };
157
178
  for (const key of keys) {
158
179
  const row = byKey.get(key);
159
180
  const def = SETTINGS_MAP.get(key);
160
181
  result.set(key, { stored: row ? "unreadable" : "none", value: resolveFallback(def) });
161
182
  if (!row) continue;
162
183
  try {
163
- const value = await decryptValue(row.value);
164
- const validated = def ? validateSettingValue(def, value) : { ok: true as const, value };
165
- if (validated.ok) result.set(key, { stored: "readable", value: validated.value });
184
+ const raw = await decryptValue(row.value);
185
+ const bound = await resolveBoundSetting(key, raw, resolve);
186
+ const validated = def ? validateSettingValue(def, bound.value) : { ok: true as const, value: bound.value };
187
+ if (validated.ok)
188
+ result.set(key, { stored: "readable", value: validated.value, ...(bound.mismatch ? { bindingMismatch: true } : {}) });
166
189
  } catch {
167
190
  // Keep this process's fallback without changing the stored row or cache.
168
191
  }
@@ -220,6 +243,37 @@ export const deleteLegacyKeys = async (extraKnownKeys: readonly string[] = []):
220
243
  return { deleted };
221
244
  };
222
245
 
246
+ /** Serialize a setting and every value it discards, including missing rows. */
247
+ export const lockSettingChanges = async (tx: SqlClient, key: string): Promise<void> => {
248
+ const owners = SETTINGS.filter((def) => def.resetOnChange?.length && (def.key === key || def.resetOnChange.includes(key)));
249
+ for (const owner of owners.sort((a, b) => a.key.localeCompare(b.key)))
250
+ await tx`SELECT pg_advisory_xact_lock(hashtextextended(${`settings.change:${owner.key}`}, 0))`;
251
+ };
252
+
253
+ /** Keep an empty row so environment bootstrap cannot restore discarded access. */
254
+ export const resetChangedSettings = async (
255
+ tx: SqlClient,
256
+ key: string,
257
+ value: unknown,
258
+ preserve: ReadonlySet<string> = new Set(),
259
+ prior?: SettingRead,
260
+ ): Promise<void> => {
261
+ const def = SETTINGS_MAP.get(key);
262
+ if (!def?.resetOnChange?.length) return;
263
+ const previous = prior ?? (await readKeyState(key, tx));
264
+ if (previous.stored !== "unreadable" && JSON.stringify(previous.value) === JSON.stringify(value)) return;
265
+ for (const target of def.resetOnChange) {
266
+ if (preserve.has(target)) continue;
267
+ const targetDef = SETTINGS_MAP.get(target);
268
+ if (!targetDef) throw new Error(`Unknown setting: ${target}`);
269
+ const empty = await encryptValue(targetDef.default);
270
+ await tx`
271
+ INSERT INTO settings.entries (key, value, updated_at) VALUES (${target}, ${empty}, now())
272
+ ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at
273
+ `;
274
+ }
275
+ };
276
+
223
277
  /**
224
278
  * Encrypt the value, upsert the DB row, invalidate the Redis key.
225
279
  *
@@ -238,8 +292,16 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient, opti
238
292
 
239
293
  const storedValue =
240
294
  def.kind === "image" && typeof validated.value === "string" ? stripImageDataUrlMetadata(validated.value) : validated.value;
241
- const encrypted = await encryptValue(storedValue);
242
295
  const write = async (tx: SqlClient) => {
296
+ await lockSettingChanges(tx, key);
297
+ await resetChangedSettings(tx, key, validated.value);
298
+ const encrypted = await encryptValue(
299
+ await bindSettingValue(key, storedValue, async (owner) => {
300
+ const state = await readKeyState(owner, tx);
301
+ if (state.stored === "unreadable") throw new SettingUnreadableError(owner);
302
+ return state.value;
303
+ }),
304
+ );
243
305
  for (let attempt = 0; attempt < 3; attempt++) {
244
306
  let [row] = await tx<{ value: string }[]>`UPDATE settings.entries SET value = value WHERE key = ${key} RETURNING value`;
245
307
  if (!row) {
@@ -256,7 +318,7 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient, opti
256
318
  if (options?.replaceUnreadable !== true) {
257
319
  let readable = false;
258
320
  try {
259
- readable = validateSettingValue(def, await decryptValue(row.value)).ok;
321
+ readable = validateSettingValue(def, unwrapSettingValue(key, await decryptValue(row.value))).ok;
260
322
  } catch {
261
323
  // Only the key is exposed by the recovery error.
262
324
  }
@@ -283,7 +345,10 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient, opti
283
345
 
284
346
  /** Delete the DB row and invalidate Redis. With `db`, the caller owns geo budget pair validation and post-commit invalidation. */
285
347
  export const deleteKey = async (key: string, db?: SqlClient): Promise<void> => {
348
+ const def = SETTINGS_MAP.get(key);
286
349
  const remove = async (tx: SqlClient) => {
350
+ await lockSettingChanges(tx, key);
351
+ await resetChangedSettings(tx, key, resolveFallback(def));
287
352
  await tx`DELETE FROM settings.entries WHERE key = ${key}`;
288
353
  };
289
354
  if (db) await remove(db);
@@ -0,0 +1,40 @@
1
+ import { BlockList, isIP } from "node:net";
2
+ import { parseNetworkAllowlist } from "../../shared/network-address";
3
+
4
+ export const DEFAULT_TRUSTED_PROXIES = ["127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7"];
5
+
6
+ /** One parser for admin writes and the gateway's startup check. */
7
+ export const trustedProxySet = (entries: readonly string[]): BlockList => {
8
+ const set = new BlockList();
9
+ for (const entry of entries) {
10
+ const [address = "", prefix, ...rest] = entry.split("/");
11
+ const version = isIP(address);
12
+ const max = version === 4 ? 32 : 128;
13
+ if (!version || rest.length || address.includes("%") || (prefix !== undefined && !/^\d{1,3}$/.test(prefix)))
14
+ throw new Error("Trusted proxies must contain only IP addresses or CIDR ranges");
15
+ const bits = prefix === undefined ? max : Number(prefix);
16
+ if (bits < 1 || bits > max) throw new Error("Trusted proxy CIDR prefixes must be greater than zero and within the address width");
17
+ set.addSubnet(address, bits, version === 4 ? "ipv4" : "ipv6");
18
+ }
19
+ return set;
20
+ };
21
+
22
+ export const validateTrustedProxies = (value: unknown): string | undefined => {
23
+ if (!Array.isArray(value) || !value.every((entry): entry is string => typeof entry === "string"))
24
+ return "Trusted proxies must be a list of strings";
25
+ try {
26
+ trustedProxySet(value);
27
+ } catch (error) {
28
+ return error instanceof Error ? error.message : "Invalid trusted proxies";
29
+ }
30
+ };
31
+
32
+ export const validateNetworkAllowlist = (value: unknown): string | undefined => {
33
+ if (!Array.isArray(value) || !value.every((entry): entry is string => typeof entry === "string"))
34
+ return "Network targets must be a list of strings";
35
+ try {
36
+ parseNetworkAllowlist(value);
37
+ } catch {
38
+ return "Network targets must be exact host names, IP addresses or canonical CIDRs; /0 is forbidden. Host:port and [ipv6]:port are supported.";
39
+ }
40
+ };
@@ -1,5 +1,16 @@
1
1
  // Cloud-specific shared utils (NOT in stdlib)
2
2
 
3
+ // The standalone editor owns the formula engine; Cloud preserves its existing shared API.
4
+ export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "@k2b/editor/formula";
5
+ export {
6
+ createProgressValue,
7
+ evaluateFormula,
8
+ evaluateTableCell,
9
+ formatValue,
10
+ isFormula,
11
+ isTotalRow,
12
+ parseProgressValue,
13
+ } from "@k2b/editor/formula";
3
14
  // Re-export from stdlib for backward compatibility
4
15
  // Prefer importing directly from @k2b/stdlib
5
16
  export { dates, dates as calendar, encoding, fileIcons, gradients } from "@k2b/stdlib";
@@ -49,16 +60,6 @@ export * from "./locale-preference";
49
60
  export * from "./login-method";
50
61
  export { markdown } from "./markdown";
51
62
  export { markdownCalloutSanitizeOptions } from "./markdown/callout-sanitizer";
52
- export type { ErrorCode, EvalContext, EvalError, EvalResult, EvalValue, ProgressValue } from "./markdown/formula";
53
- export {
54
- createProgressValue,
55
- evaluateFormula,
56
- evaluateTableCell,
57
- formatValue,
58
- isFormula,
59
- isTotalRow,
60
- parseProgressValue,
61
- } from "./markdown/formula";
62
63
  export type { MockCover, MockCoverIcon, MockCoverOptions, MockCoverTheme } from "./mock-cover";
63
64
  export { createMockCover, createMockCoverSvg, parseDataUrl } from "./mock-cover";
64
65
  export * from "./network-address";
@@ -20,8 +20,17 @@
20
20
  * NULL/datetime cell-formatting, no zebra columns. If you need any of
21
21
  * those, you're rendering data not prose; use the Grids app.
22
22
  */
23
+
24
+ import {
25
+ type EvalContext,
26
+ evaluateFormula,
27
+ formatValue,
28
+ isFormula,
29
+ isTotalRow,
30
+ type ProgressValue,
31
+ parseProgressValue,
32
+ } from "@k2b/editor/formula";
23
33
  import type { MarkedExtension, Tokens } from "marked";
24
- import { type EvalContext, evaluateFormula, formatValue, isFormula, isTotalRow, type ProgressValue, parseProgressValue } from "../formula";
25
34
  import { escapeHtml } from "../shared";
26
35
 
27
36
  type Align = "left" | "right" | "center" | null;