@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.
- package/package.json +8 -4
- package/scripts/build.ts +5 -1
- package/scripts/preload.ts +5 -1
- package/src/_internal/define-app.ts +38 -0
- package/src/_internal/heartbeat.ts +19 -0
- package/src/_internal/postgres-application-name.ts +11 -0
- package/src/_internal/process-sync.ts +9 -0
- package/src/_internal/readiness.ts +6 -1
- package/src/_internal/resource-principals.ts +4 -34
- package/src/_internal/workload-credential.ts +153 -0
- package/src/_internal/workload-protocol.ts +36 -0
- package/src/ai/application-tasks.ts +5 -2
- package/src/ai/chart-block.ts +36 -711
- package/src/ai/default-tool-contracts.ts +21 -259
- package/src/api/admin-app-keys.ts +66 -0
- package/src/api/admin-core-settings.ts +2 -0
- package/src/api/admin-identity.ts +27 -1
- package/src/api/admin-settings-env.ts +120 -0
- package/src/api/capabilities.ts +9 -3
- package/src/api/index.ts +3 -0
- package/src/api/settings-ops.ts +35 -0
- package/src/app-key.ts +125 -0
- package/src/browser/CloudResourceSearch.tsx +48 -41
- package/src/capabilities/discussion-providers.ts +3 -2
- package/src/capabilities/resource-readers.ts +13 -2
- package/src/capabilities/response.ts +4 -1
- package/src/capabilities/server-invoke.ts +46 -28
- package/src/capabilities/server.ts +1 -1
- package/src/capabilities/types.ts +1 -1
- package/src/cli/admin/app-credentials.ts +32 -7
- package/src/cli/admin/app-keys.ts +226 -0
- package/src/cli/admin/index.ts +7 -1
- package/src/cli/admin/settings.ts +62 -0
- package/src/config/define-env.ts +63 -8
- package/src/config/env.ts +27 -21
- package/src/config/index.ts +12 -1
- package/src/config/workflow-ai.ts +28 -0
- package/src/contracts/settings-env.ts +21 -0
- package/src/contracts/settings-types.ts +6 -0
- package/src/index.ts +9 -0
- package/src/runtime/clients.ts +72 -0
- package/src/runtime/connections.ts +37 -0
- package/src/runtime/resolve-connections.ts +91 -0
- package/src/server/client-ip.ts +1 -1
- package/src/services/app-keys.ts +298 -0
- package/src/services/gateway.ts +6 -0
- package/src/services/identity/index.ts +3 -0
- package/src/services/identity/resource-readers.ts +80 -0
- package/src/services/identity/runtime-config.ts +14 -6
- package/src/services/identity/workload-runtime.ts +253 -0
- package/src/services/index.ts +4 -1
- package/src/services/outgoing-mail/resolve-host.ts +5 -4
- package/src/services/service-account-credentials.ts +63 -21
- package/src/services/settings/api.ts +3 -3
- package/src/services/settings/binding.ts +45 -0
- package/src/services/settings/core-settings.ts +99 -1
- package/src/services/settings/defaults.ts +16 -2
- package/src/services/settings/env-status.ts +69 -0
- package/src/services/settings/index.ts +69 -26
- package/src/services/settings/store.ts +73 -8
- package/src/services/settings/validation.ts +40 -0
- package/src/shared/index.ts +11 -10
- package/src/shared/markdown/extensions/tables.ts +10 -1
- package/src/start.ts +36 -0
- package/src/workflows/ai/index.ts +1 -0
- package/src/workflows/ai/runtime.ts +16 -8
- package/src/workflows/ai/settings.ts +5 -0
- package/src/shared/markdown/formula.ts +0 -1283
|
@@ -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
|
+
];
|
package/src/cli/admin/index.ts
CHANGED
|
@@ -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
|
|
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
|
+
];
|
package/src/config/define-env.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
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> = {
|
|
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;
|
|
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(
|
|
92
|
-
default:
|
|
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`
|
|
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
|
-
|
|
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
|
|
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(),
|
package/src/config/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
};
|