@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
@@ -0,0 +1,226 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, chown, link, lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { z } from "zod";
5
+ import { appKeyFingerprint, deriveAppPublicKey, formatAppKey, generateAppKeyPair, validateAppKeyAppId } from "../../app-key";
6
+ import { arg, command, confirmFlag, flag, printStructured } from "../index";
7
+ import { apiGet, apiJson } from "./shared";
8
+
9
+ const keyPath = (appId: string) => `/api/admin/apps/${encodeURIComponent(appId)}/keys`;
10
+ const registrationSchema = z.object({
11
+ keyId: z.uuid(),
12
+ installationId: z.uuid(),
13
+ fingerprint: z.string().regex(/^[0-9a-f]{32}$/),
14
+ status: z.enum(["active", "expired", "revoked"]),
15
+ accountStatus: z.enum(["active", "disabled"]),
16
+ });
17
+ const enrollmentSchema = z
18
+ .object({ version: z.literal(1), server: z.string(), appId: z.string(), seed: z.string(), publicKey: z.string() })
19
+ .strict();
20
+ export type AppKeyArtifactFormat = "file" | "env" | "compose" | "k8s-secret";
21
+
22
+ export const renderAppKeyArtifact = (appId: string, privateKey: string, format: AppKeyArtifactFormat): string => {
23
+ validateAppKeyAppId(appId);
24
+ if (format === "file") return `${privateKey}\n`;
25
+ if (format === "env") return `CLOUD_APP_KEY=${privateKey}\n`;
26
+ if (format === "compose") return `services:\n ${appId}:\n environment:\n CLOUD_APP_KEY: ${JSON.stringify(privateKey)}\n`;
27
+ const secretName =
28
+ appId.length <= 50
29
+ ? `${appId}-app-key`
30
+ : `${appId.slice(0, 40)}-${createHash("sha256").update(appId).digest("hex").slice(0, 8)}-app-key`;
31
+ return `apiVersion: v1\nkind: Secret\nmetadata:\n name: ${secretName}\ntype: Opaque\nstringData:\n CLOUD_APP_KEY: ${JSON.stringify(privateKey)}\n`;
32
+ };
33
+
34
+ const isCode = (error: unknown, code: string): boolean => error instanceof Error && "code" in error && error.code === code;
35
+ const owner = async (path: string, uid?: number) => {
36
+ await chmod(path, 0o600);
37
+ if (uid !== undefined) await chown(path, uid, -1);
38
+ };
39
+
40
+ /** Publish the fully written, fsynced artifact atomically. Exclusive publication
41
+ * elects the same provisional key even when two CLI processes start together. */
42
+ const atomicWrite = async (path: string, value: string, options: { exclusive?: boolean; uid?: number } = {}): Promise<boolean> => {
43
+ const temporary = `${path}.${crypto.randomUUID()}.tmp`;
44
+ const handle = await open(temporary, "wx", 0o600);
45
+ try {
46
+ await handle.writeFile(value, "utf8");
47
+ await handle.sync();
48
+ await owner(temporary, options.uid);
49
+ await handle.close();
50
+ let published = true;
51
+ if (options.exclusive) {
52
+ try {
53
+ await link(temporary, path);
54
+ } catch (error) {
55
+ if (!isCode(error, "EEXIST")) throw error;
56
+ published = false;
57
+ }
58
+ } else await rename(temporary, path);
59
+ const directory = await open(resolve(path, ".."), "r");
60
+ try {
61
+ await directory.sync();
62
+ } finally {
63
+ await directory.close();
64
+ }
65
+ return published;
66
+ } finally {
67
+ await handle.close();
68
+ await unlink(temporary).catch((error: unknown) => {
69
+ if (!isCode(error, "ENOENT")) throw error;
70
+ });
71
+ }
72
+ };
73
+
74
+ export const enrollAppKey = async (input: {
75
+ appId: string;
76
+ server: string;
77
+ outDir: string;
78
+ format: AppKeyArtifactFormat;
79
+ uid?: number;
80
+ name?: string;
81
+ register: (body: { publicKey: string; name?: string }) => Promise<unknown>;
82
+ }) => {
83
+ validateAppKeyAppId(input.appId);
84
+ if (input.uid !== undefined && process.getuid?.() !== 0 && process.getuid?.() !== input.uid)
85
+ throw new Error("--uid requires root or the current user ID");
86
+ const outDir = resolve(input.outDir);
87
+ await mkdir(outDir, { recursive: true, mode: 0o700 });
88
+ const provisional = join(outDir, `.${input.appId}.enrollment.json`);
89
+ const extension = { file: "key", env: "env", compose: "compose.yaml", "k8s-secret": "secret.yaml" }[input.format];
90
+ const file = join(outDir, `${input.appId}.${extension}`);
91
+ const server = input.server.replace(/\/$/, "");
92
+ let state: z.infer<typeof enrollmentSchema>;
93
+ try {
94
+ state = enrollmentSchema.parse(JSON.parse(await readFile(provisional, "utf8")));
95
+ } catch (error) {
96
+ if (!isCode(error, "ENOENT"))
97
+ throw new Error(`Cannot read enrollment state for ${input.appId}; preserve it and repair the file before retrying`);
98
+ try {
99
+ await lstat(file);
100
+ throw new Error("Output already exists without an enrollment record; preserve it and use a new output directory");
101
+ } catch (error) {
102
+ if (!isCode(error, "ENOENT")) throw error;
103
+ }
104
+ const pair = await generateAppKeyPair();
105
+ await atomicWrite(provisional, JSON.stringify({ version: 1, server, appId: input.appId, ...pair }), {
106
+ exclusive: true,
107
+ uid: input.uid,
108
+ });
109
+ state = enrollmentSchema.parse(JSON.parse(await readFile(provisional, "utf8")));
110
+ }
111
+ if (state.server !== server || state.appId !== input.appId)
112
+ throw new Error("Enrollment belongs to a different server or app; use another output directory");
113
+ if ((await deriveAppPublicKey(state.seed)) !== state.publicKey) throw new Error("Enrollment public key does not match its seed");
114
+ await owner(provisional, input.uid);
115
+ const registered = registrationSchema.parse(
116
+ await input.register({ publicKey: state.publicKey, ...(input.name ? { name: input.name } : {}) }),
117
+ );
118
+ if (registered.fingerprint !== (await appKeyFingerprint(state.publicKey)))
119
+ throw new Error("Core returned a different app-key fingerprint");
120
+ if (registered.status !== "active")
121
+ throw new Error(`The enrolled key is ${registered.status}; use a new output directory to generate a replacement key`);
122
+ if (registered.accountStatus !== "active") throw new Error("The app service account is disabled; enable it before using this key");
123
+ const privateKey = formatAppKey({
124
+ appId: input.appId,
125
+ installationId: registered.installationId,
126
+ keyId: registered.keyId,
127
+ seed: state.seed,
128
+ });
129
+ await atomicWrite(file, renderAppKeyArtifact(input.appId, privateKey, input.format), { uid: input.uid });
130
+ // Retain the provisional seed for safe repeats after any lost API/file response.
131
+ // A rotation uses a new directory; repeating create never silently rotates.
132
+ return { appId: input.appId, ...registered, file, ...(input.format === "file" ? { environment: { CLOUD_APP_KEY_FILE: file } } : {}) };
133
+ };
134
+
135
+ export const resolveAppKeyApps = async (positional: string[], all: boolean, apps?: string): Promise<string[]> => {
136
+ if (all && (positional.length > 0 || !apps)) throw new Error("--all requires --apps <comma-separated-list|file> and no positional apps");
137
+ if (!all && apps) throw new Error("--apps requires --all");
138
+ let selected = positional;
139
+ if (all && apps) {
140
+ let raw: string;
141
+ try {
142
+ raw = await readFile(apps, "utf8");
143
+ } catch (error) {
144
+ if (!isCode(error, "ENOENT")) throw error;
145
+ raw = apps;
146
+ }
147
+ selected = raw.split(/[\s,]+/).filter(Boolean);
148
+ }
149
+ if (selected.length === 0) throw new Error("Name at least one app or use --all --apps <list|file>");
150
+ return [...new Set(selected.map(validateAppKeyAppId))];
151
+ };
152
+
153
+ export const appKeyCommands = [
154
+ command("apps keys create", {
155
+ summary: "Generate app keys locally and resume enrollment into owner-only files",
156
+ args: { apps: arg.rest({ valueLabel: "app" }) },
157
+ flags: {
158
+ all: flag.boolean({ description: "Enroll the explicit --apps list" }),
159
+ apps: flag.string({ description: "Comma/space-separated app IDs or a file containing that list" }),
160
+ outDir: flag.string({ required: true, description: "Directory for secret artifacts and resumable enrollment state" }),
161
+ format: flag.enum(["file", "env", "compose", "k8s-secret"] as const, { default: "file" }),
162
+ uid: flag.int({ min: 0, description: "File owner UID; requires root or the current UID" }),
163
+ name: flag.string({ description: "Human-readable key name" }),
164
+ },
165
+ async run({ ctx, args, flags }) {
166
+ const apps = await resolveAppKeyApps(args.apps, flags.all, flags.apps);
167
+ const results = [];
168
+ for (const appId of apps)
169
+ results.push(
170
+ await enrollAppKey({
171
+ appId,
172
+ server: ctx.options.server,
173
+ outDir: flags.outDir!,
174
+ format: flags.format ?? "file",
175
+ uid: flags.uid,
176
+ name: flags.name,
177
+ register: (body) => apiJson(ctx, "POST", keyPath(appId), body),
178
+ }),
179
+ );
180
+ if (!printStructured(ctx, results))
181
+ ctx.table(results, [
182
+ { key: "appId", label: "App" },
183
+ { key: "keyId", label: "Key ID" },
184
+ { key: "file", label: "File" },
185
+ ]);
186
+ },
187
+ }),
188
+ command("apps keys list", {
189
+ summary: "List one app's public keys and lifecycle metadata",
190
+ args: { app: arg.required({ valueLabel: "app" }) },
191
+ flags: { page: flag.int({ min: 1, default: 1 }), perPage: flag.int({ min: 1, max: 500, default: 100 }) },
192
+ async run({ ctx, args, flags }) {
193
+ validateAppKeyAppId(args.app);
194
+ const result = z
195
+ .object({
196
+ installationId: z.uuid(),
197
+ items: z.array(
198
+ z
199
+ .object({ keyId: z.string(), name: z.string(), fingerprint: z.string(), status: z.string(), accountStatus: z.string() })
200
+ .passthrough(),
201
+ ),
202
+ })
203
+ .passthrough()
204
+ .parse(await apiGet(ctx, `${keyPath(args.app)}?page=${flags.page}&perPage=${flags.perPage}`));
205
+ if (!printStructured(ctx, result))
206
+ ctx.table(result.items, [
207
+ { key: "keyId", label: "Key ID" },
208
+ { key: "name", label: "Name" },
209
+ { key: "fingerprint", label: "Fingerprint" },
210
+ { key: "status", label: "Status" },
211
+ { key: "accountStatus", label: "Account" },
212
+ ]);
213
+ },
214
+ }),
215
+ command("apps keys revoke", {
216
+ summary: "Revoke one app key idempotently",
217
+ args: { app: arg.required({ valueLabel: "app" }), keyId: arg.required({ valueLabel: "keyId" }) },
218
+ flags: { yes: confirmFlag("Revoke this app key") },
219
+ async run({ ctx, args, flags }) {
220
+ if (!flags.yes) throw new Error("Revoking an app key requires --yes");
221
+ validateAppKeyAppId(args.app);
222
+ const result = await apiJson(ctx, "DELETE", `${keyPath(args.app)}/${z.uuid().parse(args.keyId)}`);
223
+ if (!printStructured(ctx, result)) ctx.print("App key revoked.");
224
+ },
225
+ }),
226
+ ];
@@ -14,6 +14,7 @@ import { aiQuotaCommands } from "./ai-quotas";
14
14
  import { aiSkillCommands } from "./ai-skills";
15
15
  import { aiUsageCommands } from "./ai-usage";
16
16
  import { appCredentialCommands } from "./app-credentials";
17
+ import { appKeyCommands } from "./app-keys";
17
18
  import { appSignInCommands } from "./app-sign-in";
18
19
  import { dataCommands } from "./data";
19
20
  import { documentationCommands } from "./documentation";
@@ -28,6 +29,7 @@ import { metricsCommands } from "./metrics";
28
29
  import { natsCommands } from "./nats";
29
30
  import { notificationCommands } from "./notifications";
30
31
  import { outgoingMailCommands } from "./outgoing-mail";
32
+ import { settingsCommands } from "./settings";
31
33
  import { syncCommands } from "./sync";
32
34
  import { telemetryCommands } from "./telemetry";
33
35
  import { webhookCommands } from "./webhooks";
@@ -51,7 +53,8 @@ export default defineCliCommands({
51
53
  agents: "Create, list, revoke, and re-key agent accounts with OAuth client credentials, and change their pictures.",
52
54
  "agents avatar": "Show and change an agent's picture: its robot parts or a photo.",
53
55
  "app-sign-in": "Manage app sign-in and pairing.",
54
- apps: "Inspect and remove registered apps.",
56
+ apps: "Inspect registered apps and manage app keys.",
57
+ "apps keys": "Create, list, and revoke Ed25519 app keys.",
55
58
  documentation: "Manage the administration documentation website.",
56
59
  geoip: "Replace and inspect sign-in location databases.",
57
60
  jobs: "Inspect background jobs and their runs.",
@@ -71,6 +74,7 @@ export default defineCliCommands({
71
74
  postgres: "Inspect Postgres diagnostics.",
72
75
  redis: "Inspect Valkey diagnostics.",
73
76
  routes: "Inspect gateway routes.",
77
+ settings: "Inspect environment migration and change administrator settings.",
74
78
  sync: "Inspect Sync resources, schedules, and dead letters.",
75
79
  "sync dead-letters": "Inspect, replay, requeue, or remove dead letters.",
76
80
  "sync resources": "List Sync resource declarations.",
@@ -90,6 +94,7 @@ export default defineCliCommands({
90
94
  },
91
95
  commands: [
92
96
  ...appCredentialCommands,
97
+ ...appKeyCommands,
93
98
  ...agentCommands,
94
99
  ...documentationCommands,
95
100
  ...appSignInCommands,
@@ -115,5 +120,6 @@ export default defineCliCommands({
115
120
  ...metricsCommands,
116
121
  ...natsCommands,
117
122
  ...syncCommands,
123
+ ...settingsCommands,
118
124
  ],
119
125
  });
@@ -0,0 +1,62 @@
1
+ import { EnvSettingsStatusSchema } from "../../contracts/settings-env";
2
+ import { arg, command, confirmFlag, flag, printStructured } from "../index";
3
+ import { apiGet, apiJson } from "./shared";
4
+
5
+ export const settingsCommands = [
6
+ command("settings status", {
7
+ summary: "Check environment-backed settings across registered processes without printing values",
8
+ flags: { env: flag.boolean({ description: "Check stored values against each running application's environment" }) },
9
+ async run({ ctx, flags }) {
10
+ if (!flags.env) throw new Error("Use settings status --env.");
11
+ const result = EnvSettingsStatusSchema.parse(await apiGet(ctx, "/api/admin/core/settings/env/status"));
12
+ if (!printStructured(ctx, result)) {
13
+ for (const process of result.processes) {
14
+ ctx.print(`${process.appId}: ${process.status}`);
15
+ ctx.table(
16
+ process.settings,
17
+ [
18
+ "key",
19
+ "envVariable",
20
+ "storedValuePresent",
21
+ "envValuePresent",
22
+ "conflict",
23
+ "unreadableStoredValue",
24
+ "invalidEnvValue",
25
+ "needsAttention",
26
+ ].map((key) => ({ key })),
27
+ );
28
+ }
29
+ }
30
+ return result.needsAttention ? 1 : 0;
31
+ },
32
+ }),
33
+ command("settings set", {
34
+ summary: "Set a whole administrator setting value",
35
+ args: { key: arg.required() },
36
+ flags: {
37
+ value: flag.string({ required: true, description: "JSON value (quote a text value as JSON)" }),
38
+ app: flag.string({ description: "Owning application ID; omit for platform settings" }),
39
+ replaceUnreadable: flag.boolean({ description: "Explicitly replace an unreadable stored row with this whole value" }),
40
+ yes: confirmFlag("Confirm setting change"),
41
+ },
42
+ async run({ ctx, args, flags }) {
43
+ if (!flags.yes) throw new Error("Setting changes require --yes.");
44
+ let value: unknown;
45
+ try {
46
+ value = JSON.parse(flags.value ?? "");
47
+ } catch {
48
+ throw new Error("--value must be valid JSON.");
49
+ }
50
+ const result = flags.app
51
+ ? await apiJson(ctx, "PUT", `/api/admin/core/settings/env/${encodeURIComponent(flags.app)}/${encodeURIComponent(args.key)}`, {
52
+ value,
53
+ replaceUnreadable: flags.replaceUnreadable,
54
+ })
55
+ : await apiJson(ctx, "PUT", "/api/admin/core/settings", {
56
+ updates: { [args.key]: value },
57
+ replaceUnreadable: flags.replaceUnreadable ? [args.key] : [],
58
+ });
59
+ if (!printStructured(ctx, result)) ctx.print("Setting saved.");
60
+ },
61
+ }),
62
+ ];
@@ -4,10 +4,12 @@
4
4
  * Every runtime environment variable is declared once with a schema, a
5
5
  * default, a scope and a one-sentence doc. Registries read `process.env`
6
6
  * lazily: each key is parsed on access, so tests can change variables and
7
- * consumers never depend on an import-time snapshot. `.env.example`,
7
+ * consumers never depend on an import-time snapshot. Configured secret files
8
+ * are also validated when the registry is created. `.env.example`,
8
9
  * `.env.prod.example` and the configuration reference are generated from the
9
10
  * `$specs` of every registry by `scripts/generate-config-docs.ts`.
10
11
  */
12
+ import { readFileSync } from "node:fs";
11
13
  import { z } from "zod";
12
14
 
13
15
  /**
@@ -17,7 +19,7 @@ import { z } from "zod";
17
19
  export type EnvScope = "runtime" | "secret" | "development";
18
20
 
19
21
  export type EnvSpec<T> = {
20
- /** Parses the trimmed raw value. Empty and unset values never reach the schema. */
22
+ /** Parses the raw value (trimmed for env, one trailing newline removed for files). Empty and unset values never reach the schema. */
21
23
  schema: z.ZodType<T, unknown>;
22
24
  /** Used when the variable is unset or empty; a function is evaluated on every access. */
23
25
  default?: T | (() => T);
@@ -31,6 +33,8 @@ export type EnvSpec<T> = {
31
33
  required?: boolean;
32
34
  /** Alternative variable names, consulted after the canonical key. */
33
35
  aliases?: string[];
36
+ /** Allow NAME_FILE to supply the secret. Configured files are validated when the registry is created. */
37
+ secretFile?: boolean;
34
38
  };
35
39
 
36
40
  export type EnvSpecs = Record<string, EnvSpec<unknown>>;
@@ -42,20 +46,59 @@ type EnvValue<S extends EnvSpec<unknown>> = S extends { default: unknown } | { r
42
46
  export type EnvRegistry<S extends EnvSpecs> = { readonly [K in keyof S]: EnvValue<S[K]> } & {
43
47
  readonly $specs: S;
44
48
  readonly $keys: readonly string[];
49
+ /** Read only an explicitly supplied value, without the code default. */
50
+ readonly $readPresent: <K extends keyof S & string>(key: K) => z.output<S[K]["schema"]> | undefined;
45
51
  };
46
52
 
47
- const rawValue = (key: string, aliases: string[] = []): string | undefined => {
48
- for (const name of [key, ...aliases]) {
53
+ /** Effective value source; empty values fall through to the default or unset. */
54
+ export type EnvSource = "env" | "alias" | "file" | "default" | "unset";
55
+
56
+ type RawEnv = { value: string | undefined; source: "env" | "alias" | "file" | "unset" };
57
+
58
+ // A mounted secret is a startup input, not a live configuration channel. A new
59
+ // path is still observed when tests or tooling deliberately change process.env.
60
+ const fileValues = new WeakMap<EnvSpec<unknown>, { path: string; value: string }>();
61
+
62
+ const rawValue = (key: string, spec: EnvSpec<unknown>): RawEnv => {
63
+ const names = [key, ...(spec.aliases ?? [])];
64
+ const fileKey = `${key}_FILE`;
65
+ const path = spec.secretFile ? process.env[fileKey]?.trim() || undefined : undefined;
66
+ if (path !== undefined) {
67
+ const conflict = names.find((name) => process.env[name]?.trim());
68
+ if (conflict) throw new Error(`${conflict} and ${fileKey} must not both be set`);
69
+ const cached = fileValues.get(spec);
70
+ let value = cached?.path === path ? cached.value : undefined;
71
+ if (value === undefined) {
72
+ try {
73
+ value = readFileSync(path, "utf8");
74
+ } catch {
75
+ // File errors must identify the source without including contents or an underlying error.
76
+ throw new Error(`${fileKey} could not read file ${JSON.stringify(path)}`);
77
+ }
78
+ if (value.endsWith("\r\n")) value = value.slice(0, -2);
79
+ else if (value.endsWith("\n")) value = value.slice(0, -1);
80
+ fileValues.set(spec, { path, value });
81
+ }
82
+ return { value: value === "" ? undefined : value, source: value === "" ? "unset" : "file" };
83
+ }
84
+ for (const name of names) {
49
85
  const value = process.env[name]?.trim();
50
- if (value) return value;
86
+ if (value) return { value, source: name === key ? "env" : "alias" };
51
87
  }
52
- return undefined;
88
+ return { value: undefined, source: "unset" };
89
+ };
90
+
91
+ /** Inspect the effective source without evaluating defaults; invalid file configuration throws as on value access. */
92
+ export const envSource = <S extends EnvSpecs>(registry: EnvRegistry<S>, key: keyof S & string): EnvSource => {
93
+ const spec = registry.$specs[key]!;
94
+ const { source } = rawValue(key, spec);
95
+ return source === "unset" && spec.default !== undefined ? "default" : source;
53
96
  };
54
97
 
55
98
  const isFactory = <T>(value: T | (() => T)): value is () => T => typeof value === "function";
56
99
 
57
100
  export const readEnv = <T>(key: string, spec: EnvSpec<T>): T | undefined => {
58
- const raw = rawValue(key, spec.aliases);
101
+ const { value: raw } = rawValue(key, spec);
59
102
  if (raw === undefined) {
60
103
  if (spec.default !== undefined) return isFactory(spec.default) ? spec.default() : spec.default;
61
104
  if (spec.required) throw new Error(`${key} is required`);
@@ -63,6 +106,7 @@ export const readEnv = <T>(key: string, spec: EnvSpec<T>): T | undefined => {
63
106
  }
64
107
  const parsed = spec.schema.safeParse(raw);
65
108
  if (!parsed.success) {
109
+ if (spec.secretFile) throw new Error(`${key} is invalid`);
66
110
  const reason = parsed.error.issues.map((issue) => issue.message).join("; ") || "is invalid";
67
111
  throw new Error(`${key} ${reason}`);
68
112
  }
@@ -71,8 +115,19 @@ export const readEnv = <T>(key: string, spec: EnvSpec<T>): T | undefined => {
71
115
 
72
116
  export const defineEnv = <S extends EnvSpecs>(specs: S): EnvRegistry<S> => {
73
117
  const keys = Object.freeze(Object.keys(specs));
74
- const registry: Record<string, unknown> = { $specs: specs, $keys: keys };
118
+ const registry: Record<string, unknown> = {
119
+ $specs: specs,
120
+ $keys: keys,
121
+ $readPresent: (key: string) => {
122
+ const spec = specs[key];
123
+ if (!spec) throw new Error(`Unknown environment variable: ${key}`);
124
+ return readEnv(key, { ...spec, default: undefined, required: false });
125
+ },
126
+ };
75
127
  for (const key of keys) {
128
+ const spec = specs[key]!;
129
+ // Fail during module initialization, even when this secret is first used by a later request.
130
+ if (spec.secretFile && process.env[`${key}_FILE`]?.trim()) readEnv(key, spec);
76
131
  Object.defineProperty(registry, key, { enumerable: true, configurable: true, get: () => readEnv(key, specs[key]!) });
77
132
  }
78
133
  // The getters above are the only way to build a typed lazy object.
package/src/config/env.ts CHANGED
@@ -4,23 +4,14 @@
4
4
  * `src/env.ts`; values are parsed lazily on access (see `define-env.ts`).
5
5
  */
6
6
  import { z } from "zod";
7
+ import { DEFAULT_TRUSTED_PROXIES } from "../services/settings/validation";
7
8
  import { defineEnv, envBoolean, envList } from "./define-env";
9
+ import { DEFAULT_WORKFLOW_AI_BUDGET, workflowAiBudgetSchema } from "./workflow-ai";
8
10
 
9
11
  const syncReplicas = z
10
12
  .enum(["1", "3", "5"], { error: "must be 1, 3, or 5" })
11
13
  .transform((value): 1 | 3 | 5 => (value === "1" ? 1 : value === "3" ? 3 : 5));
12
14
 
13
- const budgetSchema = z
14
- .object({
15
- maxStepsPerSource: z.number().int().positive(),
16
- tasksPerMinute: z.number().int().positive(),
17
- burst: z.number().int().positive(),
18
- sourcesPer24Hours: z.number().int().positive(),
19
- scopeConcurrent: z.number().int().positive(),
20
- globalConcurrent: z.number().int().positive(),
21
- })
22
- .strict();
23
-
24
15
  const registry = defineEnv({
25
16
  // Process
26
17
  NODE_ENV: {
@@ -35,6 +26,7 @@ const registry = defineEnv({
35
26
  // Infrastructure
36
27
  DATABASE_URL: {
37
28
  schema: z.string(),
29
+ aliases: ["DATABASEURL", "TLS_DATABASE_URL", "POSTGRES_URL", "PGURL", "PG_URL", "TLS_POSTGRES_DATABASE_URL"],
38
30
  example: "postgresql://ipa:ipa@localhost:5432/ipa",
39
31
  doc: "Postgres connection read by Bun SQL and by application-owned pools.",
40
32
  },
@@ -54,7 +46,7 @@ const registry = defineEnv({
54
46
  schema: z.string(),
55
47
  default: "",
56
48
  example: "dev",
57
- doc: "Sync namespace shared by every app of one installation; isolates its NATS resources from other installations on the same cluster.",
49
+ doc: "Sync namespace shared by every app of one installation; separates NATS resource names, not access. Use one NATS account per installation.",
58
50
  },
59
51
  SYNC_REPLICAS: {
60
52
  schema: syncReplicas,
@@ -88,9 +80,9 @@ const registry = defineEnv({
88
80
  return z.NEVER;
89
81
  }
90
82
  })
91
- .pipe(budgetSchema),
92
- default: { maxStepsPerSource: 2, tasksPerMinute: 10, burst: 20, sourcesPer24Hours: 2_000, scopeConcurrent: 1, globalConcurrent: 4 },
93
- doc: "JSON scoped workflow inference budget: active or successful logical requests per source/24h, token-bucket admission per minute and burst, distinct sources per rolling 24h, outstanding tasks per scope and globally; capacity/rate overload uses bounded workflow retries, daily/source limits fail visibly, and cache hits consume no reservation.",
83
+ .pipe(workflowAiBudgetSchema),
84
+ default: DEFAULT_WORKFLOW_AI_BUDGET,
85
+ doc: "Deprecated: bootstraps `ai.workflow_budget` once when no stored value exists; remove after `cld admin settings status --env` succeeds. JSON scoped workflow inference budget: active or successful logical requests per source/24h, token-bucket admission per minute and burst, distinct sources per rolling 24h, outstanding tasks per scope and globally; capacity/rate overload uses bounded workflow retries, daily/source limits fail visibly, and cache hits consume no reservation.",
94
86
  },
95
87
 
96
88
  // Workflow inference: p95 ~4s in the pilot; 60s bounds a whole attempt,
@@ -98,12 +90,12 @@ const registry = defineEnv({
98
90
  WORKFLOW_AI_ATTEMPT_TIMEOUT_MS: {
99
91
  schema: z.coerce.number().int().positive(),
100
92
  default: 60_000,
101
- doc: "Absolute workflow AI attempt budget in milliseconds, including admission and repair; expiry aborts the provider and follows the bounded retry policy.",
93
+ doc: "Deprecated: bootstraps `ai.workflow_attempt_timeout_ms` once when no stored value exists; remove after `cld admin settings status --env` succeeds. Absolute workflow AI attempt budget in milliseconds, including admission and repair; expiry aborts the provider and follows the bounded retry policy.",
102
94
  },
103
95
  WORKFLOW_AI_QUEUE_TIMEOUT_MS: {
104
96
  schema: z.coerce.number().int().positive(),
105
97
  default: 60_000,
106
- doc: "Maximum workflow AI queue wait in milliseconds; recovery fails overdue or lost deliveries visibly so an operator can retry the workflow.",
98
+ doc: "Deprecated: bootstraps `ai.workflow_queue_timeout_ms` once when no stored value exists; remove after `cld admin settings status --env` succeeds. Maximum workflow AI queue wait in milliseconds; recovery fails overdue or lost deliveries visibly so an operator can retry the workflow.",
107
99
  },
108
100
 
109
101
  // Installation
@@ -115,12 +107,13 @@ const registry = defineEnv({
115
107
  GOTENBERG_URL: {
116
108
  schema: z.string(),
117
109
  example: "http://localhost:3001",
118
- doc: "Internal Gotenberg base URL used to bootstrap the `gotenberg.url` PDF rendering setting; the stored setting wins once written.",
110
+ doc: "Deprecated: env only bootstraps `gotenberg.url`; remove after `cld admin settings status --env` succeeds. Internal Gotenberg base URL used to bootstrap the `gotenberg.url` PDF rendering setting; the stored setting wins once written.",
119
111
  },
120
112
  APP_SECRET: {
121
113
  schema: z.string(),
122
114
  default: "",
123
115
  scope: "secret",
116
+ secretFile: true,
124
117
  example: "change-me-generate-with-openssl-rand-hex-32",
125
118
  doc: "Deployment-wide secret for at-rest settings encryption and signed cursors; generate once with `openssl rand -hex 32` and never change it after data has been written.",
126
119
  },
@@ -128,37 +121,49 @@ const registry = defineEnv({
128
121
  schema: z.string(),
129
122
  default: "",
130
123
  scope: "secret",
124
+ secretFile: true,
131
125
  doc: "Core only: temporary first-administrator or recovery login token; remove it after normal administrator sign-in works.",
132
126
  },
133
127
 
128
+ // Gateway trust boundary
129
+ GATEWAY_TRUSTED_PROXIES: {
130
+ schema: envList,
131
+ default: DEFAULT_TRUSTED_PROXIES,
132
+ doc: "Deprecated: bootstraps `gateway.trusted_proxies` once when no stored value exists; remove after `cld admin settings status --env` succeeds. Comma-separated IP addresses or CIDR ranges of the reverse proxies allowed to name the client in `X-Forwarded-For`; the default trusts loopback and private networks, which covers Traefik on a Docker network. Set the proxy's exact address or subnet when clients on private networks reach the gateway directly or through a proxy that appends to their `X-Forwarded-For`. Invalid bootstrap input with no stored row and invalid stored proxy lists stop gateway startup.",
133
+ },
134
+
134
135
  // Outbound network
135
136
  OUTGOING_MAIL_ALLOWED_TARGETS: {
136
137
  schema: envList,
137
138
  default: [],
138
139
  scope: "runtime",
139
- doc: "Comma-separated host names, IP addresses and CIDR ranges on internal networks allowed for outgoing mail SMTP and bounce IMAP connections; unset allows only public targets, an explicit value replaces this default, and invalid entries including `/0` stop the owning process at startup.",
140
+ doc: "Deprecated: bootstraps `outgoing_mail.allowed_targets` once when no stored value exists; remove after `cld admin settings status --env` succeeds. Comma-separated host names, IP addresses and CIDR ranges on internal networks allowed for outgoing mail SMTP and bounce IMAP connections; unset allows only public targets, an explicit value replaces this default, and invalid entries including `/0` are ignored with a bootstrap warning.",
140
141
  },
141
142
 
142
143
  // Identity and OAuth
143
144
  CLOUD_IDENTITY_KEY_ENCRYPTION_KEY: {
144
145
  schema: z.string(),
145
146
  scope: "secret",
147
+ secretFile: true,
146
148
  example: "change-me-generate-with-openssl-rand-hex-32",
147
149
  doc: "Core only: 64-hex KEK for platform identity signing keys stored in Postgres; generate once with `openssl rand -hex 32` and never distribute it to other apps.",
148
150
  },
149
151
  CLOUD_IDENTITY_NEXT_KEY: {
150
152
  schema: z.string(),
151
153
  scope: "secret",
154
+ secretFile: true,
152
155
  doc: "Core only: pre-distributed next KEK before promoting it to current; cannot be combined with `CLOUD_IDENTITY_PREVIOUS_KEY`.",
153
156
  },
154
157
  CLOUD_IDENTITY_PREVIOUS_KEY: {
155
158
  schema: z.string(),
156
159
  scope: "secret",
160
+ secretFile: true,
157
161
  doc: "Core only: old KEK set during an explicit rolling rewrap of stored identity signing keys, then removed.",
158
162
  },
159
163
  CLOUD_OAUTH_BROKER_SECRET: {
160
164
  schema: z.string(),
161
165
  scope: "secret",
166
+ secretFile: true,
162
167
  doc: "Core and OAuth only: 64-hex secret that lets the OAuth app request token signatures from Core; generate independently with `openssl rand -hex 32`.",
163
168
  },
164
169
  CLOUD_CORE_INTERNAL_ORIGIN: {
@@ -168,11 +173,12 @@ const registry = defineEnv({
168
173
  CLOUD_APP_CREDENTIAL: {
169
174
  schema: z.string(),
170
175
  scope: "secret",
171
- doc: "App-bound workload credential provisioned through Core's admin workload API; background apps such as Mail need one with `identity:invoke`.",
176
+ secretFile: true,
177
+ doc: "Optional host-run app override for the automatically delivered app workload credential; provision through Core's admin workload API with `identity:invoke`.",
172
178
  },
173
179
  CLOUD_IDENTITY_JWKS_ORIGIN: {
174
180
  schema: z.string(),
175
- doc: "Optional private origin of the Core listener for session and invocation JWKS; unset fetches keys from the public issuer origin.",
181
+ doc: "Deprecated: use `CLOUD_CORE_INTERNAL_ORIGIN`; the explicit old origin remains honored for this release. Optional private origin of the Core listener for session and invocation JWKS; unset uses `CLOUD_CORE_INTERNAL_ORIGIN`, then the public issuer origin.",
176
182
  },
177
183
  CLOUD_OAUTH_JWKS_ORIGIN: {
178
184
  schema: z.string(),
@@ -4,5 +4,16 @@
4
4
  * by application `src/env.ts` files. SSR config is per-app via defineApp().
5
5
  */
6
6
 
7
- export { defineEnv, type EnvRegistry, type EnvScope, type EnvSpec, type EnvSpecs, envBoolean, envList, envString } from "./define-env";
7
+ export {
8
+ defineEnv,
9
+ type EnvRegistry,
10
+ type EnvScope,
11
+ type EnvSource,
12
+ type EnvSpec,
13
+ type EnvSpecs,
14
+ envBoolean,
15
+ envList,
16
+ envSource,
17
+ envString,
18
+ } from "./define-env";
8
19
  export { env } from "./env";
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+
3
+ export const workflowAiBudgetSchema = z
4
+ .object({
5
+ maxStepsPerSource: z.number().int().positive(),
6
+ tasksPerMinute: z.number().int().positive(),
7
+ burst: z.number().int().positive(),
8
+ sourcesPer24Hours: z.number().int().positive(),
9
+ scopeConcurrent: z.number().int().positive(),
10
+ globalConcurrent: z.number().int().positive(),
11
+ })
12
+ .strict();
13
+ export const DEFAULT_WORKFLOW_AI_BUDGET = {
14
+ maxStepsPerSource: 2,
15
+ tasksPerMinute: 10,
16
+ burst: 20,
17
+ sourcesPer24Hours: 2_000,
18
+ scopeConcurrent: 1,
19
+ globalConcurrent: 4,
20
+ };
21
+ export const validateWorkflowAiBudget = (value: unknown): string | undefined => {
22
+ try {
23
+ if (typeof value === "string" && workflowAiBudgetSchema.safeParse(JSON.parse(value)).success) return;
24
+ } catch {
25
+ /* invalid JSON */
26
+ }
27
+ return "Workflow AI budget must be a JSON object with positive whole numbers for maxStepsPerSource, tasksPerMinute, burst, sourcesPer24Hours, scopeConcurrent and globalConcurrent, without extra fields";
28
+ };