@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,21 @@
1
+ import { z } from "zod";
2
+
3
+ export const EnvSettingStatusSchema = z.object({
4
+ key: z.string(),
5
+ envVariable: z.string().nullable(),
6
+ storedValuePresent: z.boolean(),
7
+ envValuePresent: z.boolean(),
8
+ conflict: z.enum(["none", "differs", "unknown"]),
9
+ unreadableStoredValue: z.boolean(),
10
+ invalidEnvValue: z.boolean(),
11
+ needsAttention: z.boolean(),
12
+ });
13
+ export const EnvSettingsProcessSchema = z.object({
14
+ appId: z.string(),
15
+ instanceId: z.string().optional(),
16
+ sampledAt: z.number().optional(),
17
+ status: z.enum(["ok", "unavailable"]),
18
+ settings: z.array(EnvSettingStatusSchema),
19
+ });
20
+ export const EnvSettingsStatusSchema = z.object({ needsAttention: z.boolean(), processes: z.array(EnvSettingsProcessSchema) });
21
+ export type EnvSettingStatus = z.infer<typeof EnvSettingStatusSchema>;
@@ -36,6 +36,12 @@ type CommonDef = {
36
36
  presentation?: AppSettingPresentation;
37
37
  envBootstrap?: EnvResolver;
38
38
  envFallback?: EnvResolver;
39
+ /** Environment name for migration diagnostics; values are never returned. */
40
+ envVariable?: string;
41
+ /** Discard these stored settings when this normalized value changes. */
42
+ resetOnChange?: readonly string[];
43
+ /** Additional server-side validation after kind normalization. */
44
+ validate?: (value: unknown) => string | undefined;
39
45
  };
40
46
 
41
47
  type StringLikeKind = "string" | "text" | "email" | "url" | "secret" | "image" | "cron" | "timezone" | "template";
package/src/index.ts CHANGED
@@ -60,5 +60,14 @@ export type {
60
60
  NotificationSendInput,
61
61
  } from "./contracts/notification-types";
62
62
  export { notification } from "./contracts/notification-types";
63
+ export { createRedis, createSql, redis, type SqlPoolOptions, sql } from "./runtime/clients";
64
+ export {
65
+ type ConnectionSource,
66
+ connections,
67
+ type PlatformConnectionSources,
68
+ type PlatformConnections,
69
+ type PlatformConnectionValues,
70
+ } from "./runtime/connections";
63
71
  export type { HelpDefinition, HelpDefinitionDocument } from "./server/help";
64
72
  export { defineHelp } from "./server/help";
73
+ export { storage } from "./services/object-store";
@@ -0,0 +1,72 @@
1
+ import { RedisClient, type RedisOptions, SQL } from "bun";
2
+ import { postgresApplicationOptions } from "../_internal/postgres-application-name";
3
+ import { connections, hasConnections } from "./connections";
4
+
5
+ export type SqlPoolOptions = SQL.PostgresOrMySQLOptions;
6
+
7
+ /** Only the pre-start placeholder is proxied; initialized clients are native Bun objects. */
8
+ const notStarted = <T>(name: string): T => {
9
+ const fail = (): never => {
10
+ throw new Error(`${name} used before platform start`);
11
+ };
12
+ return new Proxy(fail, {
13
+ get: fail,
14
+ set: fail,
15
+ apply: fail,
16
+ construct: fail,
17
+ getPrototypeOf: fail,
18
+ defineProperty: fail,
19
+ deleteProperty: fail,
20
+ has: fail,
21
+ ownKeys: fail,
22
+ getOwnPropertyDescriptor: fail,
23
+ setPrototypeOf: fail,
24
+ isExtensible: fail,
25
+ preventExtensions: fail,
26
+ }) as T;
27
+ };
28
+
29
+ export let sql: SQL = notStarted("sql");
30
+ export let redis: RedisClient = notStarted("redis");
31
+
32
+ /** Own pool and lifecycle; defaults to the installation URL and application name. */
33
+ export const createSql = (options: SqlPoolOptions = {}): SQL => {
34
+ const state = connections();
35
+ const url = options.url ?? state.database.url;
36
+ const connection = { ...postgresApplicationOptions(state.appId, url.toString()), ...options.connection };
37
+ return new SQL({
38
+ ...options,
39
+ url,
40
+ ...(Object.keys(connection).length ? { connection } : {}),
41
+ });
42
+ };
43
+
44
+ /** Own connection and lifecycle, using the installation's Valkey URL. */
45
+ export const createRedis = (options?: RedisOptions): RedisClient => new RedisClient(connections().valkey.url, options);
46
+
47
+ export const initializeClients = async (): Promise<void> => {
48
+ let database: SQL;
49
+ try {
50
+ database = createSql();
51
+ } catch {
52
+ throw new Error("Postgres client configuration is invalid; check DATABASE_URL and native SQL options");
53
+ }
54
+ try {
55
+ const valkey = createRedis();
56
+ sql = database;
57
+ redis = valkey;
58
+ } catch {
59
+ await database.close();
60
+ throw new Error("Valkey client configuration is invalid; check REDIS_URL and native Redis options");
61
+ }
62
+ };
63
+
64
+ /** Internal test-target teardown, before a fixture selects a different database. */
65
+ export const closePlatformClients = async (): Promise<void> => {
66
+ if (hasConnections()) {
67
+ await sql.close();
68
+ redis.close();
69
+ }
70
+ sql = notStarted("sql");
71
+ redis = notStarted("redis");
72
+ };
@@ -0,0 +1,37 @@
1
+ /** Startup snapshot. Connection credentials never flow back into the process environment. */
2
+ export type ConnectionSource = "env" | "core";
3
+ export type PlatformConnectionValues = {
4
+ database: { url: string };
5
+ valkey: { url: string };
6
+ nats: {
7
+ servers: string[];
8
+ namespace: string;
9
+ replicas: 1 | 3 | 5;
10
+ ignoreClusterUpdates: boolean;
11
+ /** Mounted credentials and CA files are read once; these are contents, not paths. */
12
+ creds?: string;
13
+ ca?: string;
14
+ };
15
+ appSecret: { current: string; previous?: string; next?: string };
16
+ };
17
+ export type PlatformConnectionSources = {
18
+ [Group in keyof PlatformConnectionValues]: { [Key in keyof PlatformConnectionValues[Group]]: ConnectionSource };
19
+ };
20
+ export type PlatformConnections = PlatformConnectionValues & {
21
+ appId: string;
22
+ sources: PlatformConnectionSources;
23
+ };
24
+
25
+ let current: PlatformConnections | undefined;
26
+
27
+ export const connections = (): PlatformConnections => {
28
+ if (!current)
29
+ throw new Error("connections() used before platform start; use @k2b/cloud/start, the Cloud build entry or development preload");
30
+ return current;
31
+ };
32
+
33
+ /** Internal startup binding; tests replace it only after closing their previous clients. */
34
+ export const bindConnections = (value: PlatformConnections | undefined): void => {
35
+ current = value;
36
+ };
37
+ export const hasConnections = (): boolean => current !== undefined;
@@ -0,0 +1,91 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { envSource } from "../config/define-env";
3
+ import { env } from "../config/env";
4
+ import type { ConnectionSource, PlatformConnections, PlatformConnectionValues } from "./connections";
5
+
6
+ type EnvKey = keyof typeof env.$specs;
7
+ const explicitlySet = (key: EnvKey): boolean => ["env", "alias", "file"].includes(envSource(env, key));
8
+
9
+ /** URL diagnostics deliberately omit both the input and underlying parser errors. */
10
+ const url = (key: string, value: string | undefined, bareHost = false): string => {
11
+ if (!value) throw new Error(`${key} is required for platform start`);
12
+ try {
13
+ const parsed = new URL(bareHost && !value.includes("://") ? `nats://${value}` : value);
14
+ if (bareHost && !parsed.hostname) throw new Error();
15
+ } catch {
16
+ throw new Error(`${key} must be a valid URL${bareHost ? " or host:port" : ""}`);
17
+ }
18
+ return value;
19
+ };
20
+ const contents = (key: "NATS_CREDS_FILE" | "NATS_TLS_CA_FILE"): string | undefined => {
21
+ const path = env[key];
22
+ if (!path) return undefined;
23
+ try {
24
+ const value = readFileSync(path, "utf8");
25
+ if (!value.trim()) throw new Error();
26
+ return value;
27
+ } catch {
28
+ throw new Error(`${key} must name a readable, non-empty file`);
29
+ }
30
+ };
31
+
32
+ /**
33
+ * Explicit environment values always win, including aliases, files and false.
34
+ * The Core bootstrap client supplies `core` in key mode; required values then
35
+ * never fall through to environment defaults. Optional values may use defaults.
36
+ */
37
+ export const resolveConnections = ({ appId, core }: { appId: string; core?: PlatformConnectionValues }): PlatformConnections => {
38
+ const source = (key: EnvKey): ConnectionSource => (!core || explicitlySet(key) ? "env" : "core");
39
+ const choose = <T>(key: EnvKey, fromEnv: () => T, fromCore: T): T => (!core || explicitlySet(key) ? fromEnv() : fromCore);
40
+ // Keep value reads lazy: an absent env value must not evaluate its default in key mode.
41
+ const database = url(
42
+ "DATABASE_URL",
43
+ choose("DATABASE_URL", () => env.DATABASE_URL, core?.database.url),
44
+ );
45
+ const valkey = url(
46
+ "REDIS_URL",
47
+ choose("REDIS_URL", () => env.REDIS_URL, core?.valkey.url),
48
+ );
49
+ const servers = choose("NATS_SERVERS", () => env.NATS_SERVERS, core?.nats.servers) ?? [];
50
+ for (const server of servers) url("NATS_SERVERS", server, true);
51
+ const namespace = choose("SYNC_NAMESPACE", () => env.SYNC_NAMESPACE, core?.nats.namespace) ?? "";
52
+ const replicas = choose("SYNC_REPLICAS", () => env.SYNC_REPLICAS, core?.nats.replicas) ?? env.SYNC_REPLICAS;
53
+ const ignoreClusterUpdates =
54
+ choose("NATS_IGNORE_CLUSTER_UPDATES", () => env.NATS_IGNORE_CLUSTER_UPDATES, core?.nats.ignoreClusterUpdates) ??
55
+ env.NATS_IGNORE_CLUSTER_UPDATES;
56
+ const creds = choose("NATS_CREDS_FILE", () => contents("NATS_CREDS_FILE"), core?.nats.creds);
57
+ const ca = choose("NATS_TLS_CA_FILE", () => contents("NATS_TLS_CA_FILE"), core?.nats.ca);
58
+ const secret = choose("APP_SECRET", () => env.APP_SECRET, core?.appSecret.current) ?? "";
59
+ const rotation = core && !explicitlySet("APP_SECRET") ? core.appSecret : undefined;
60
+ if (core) {
61
+ if (servers.length === 0) throw new Error("NATS_SERVERS is required for platform start");
62
+ if (!namespace.trim()) throw new Error("SYNC_NAMESPACE is required for platform start");
63
+ if (!secret.trim()) throw new Error("APP_SECRET is required for platform start");
64
+ }
65
+ if (![1, 3, 5].includes(replicas)) throw new Error("SYNC_REPLICAS must be 1, 3, or 5");
66
+ if (typeof ignoreClusterUpdates !== "boolean") throw new Error("NATS_IGNORE_CLUSTER_UPDATES must be a boolean");
67
+ return {
68
+ appId,
69
+ database: { url: database },
70
+ valkey: { url: valkey },
71
+ nats: { servers: [...servers], namespace, replicas, ignoreClusterUpdates, creds, ca },
72
+ appSecret: { current: secret, previous: rotation?.previous, next: rotation?.next },
73
+ sources: {
74
+ database: { url: source("DATABASE_URL") },
75
+ valkey: { url: source("REDIS_URL") },
76
+ nats: {
77
+ servers: source("NATS_SERVERS"),
78
+ namespace: source("SYNC_NAMESPACE"),
79
+ replicas: source("SYNC_REPLICAS"),
80
+ ignoreClusterUpdates: source("NATS_IGNORE_CLUSTER_UPDATES"),
81
+ creds: source("NATS_CREDS_FILE"),
82
+ ca: source("NATS_TLS_CA_FILE"),
83
+ },
84
+ appSecret: {
85
+ current: source("APP_SECRET"),
86
+ ...(rotation?.previous ? { previous: "core" as const } : {}),
87
+ ...(rotation?.next ? { next: "core" as const } : {}),
88
+ },
89
+ },
90
+ };
91
+ };
@@ -1,7 +1,7 @@
1
1
  import { isIP } from "node:net";
2
2
  import type { Context } from "hono";
3
3
 
4
- // The gateway (packages/gateway/src/client-address.ts) resolves this address using GATEWAY_TRUSTED_PROXIES.
4
+ // The gateway (packages/gateway/src/client-address.ts) resolves this address using the gateway.trusted_proxies setting.
5
5
  export const clientIp = (c: Context): string =>
6
6
  c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
7
7
 
@@ -0,0 +1,298 @@
1
+ import { err, fail, ok, type Result } from "@k2b/stdlib";
2
+ import { type SQL, sql } from "bun";
3
+ import { appKeyFingerprint, validateAppKeyAppId } from "../app-key";
4
+ import type { User } from "../contracts/shared";
5
+ import { audit } from "./audit";
6
+ import { serviceAccounts } from "./service-accounts";
7
+
8
+ type AdminActor = Pick<User, "id" | "uid" | "provider" | "roles">;
9
+ export type AppKeyMetadata = {
10
+ keyId: string;
11
+ appId: string;
12
+ serviceAccountId: string;
13
+ kind: "app_key";
14
+ name: string;
15
+ publicKey: string;
16
+ fingerprint: string;
17
+ status: "active" | "expired" | "revoked";
18
+ accountStatus: "active" | "disabled";
19
+ createdAt: string;
20
+ createdBy: string | null;
21
+ expiresAt: string | null;
22
+ revokedAt: string | null;
23
+ revokedBy: string | null;
24
+ lastUsedAt: string | null;
25
+ };
26
+ type Row = {
27
+ id: string;
28
+ app_id: string;
29
+ service_account_id: string;
30
+ name: string;
31
+ public_key: string;
32
+ fingerprint: string;
33
+ key_status: AppKeyMetadata["status"];
34
+ account_status: AppKeyMetadata["accountStatus"];
35
+ created_at: Date;
36
+ created_by: string | null;
37
+ expires_at: Date | null;
38
+ revoked_at: Date | null;
39
+ revoked_by: string | null;
40
+ last_used_at: Date | null;
41
+ };
42
+ const map = (row: Row): AppKeyMetadata => ({
43
+ keyId: row.id,
44
+ appId: row.app_id,
45
+ serviceAccountId: row.service_account_id,
46
+ kind: "app_key",
47
+ name: row.name,
48
+ publicKey: row.public_key,
49
+ fingerprint: row.fingerprint,
50
+ status: row.key_status,
51
+ accountStatus: row.account_status,
52
+ createdAt: row.created_at.toISOString(),
53
+ createdBy: row.created_by,
54
+ expiresAt: row.expires_at?.toISOString() ?? null,
55
+ revokedAt: row.revoked_at?.toISOString() ?? null,
56
+ revokedBy: row.revoked_by,
57
+ lastUsedAt: row.last_used_at?.toISOString() ?? null,
58
+ });
59
+ const columns = `c.*, sa.app_id, sa.status AS account_status,
60
+ CASE WHEN c.status = 'revoked' THEN 'revoked' WHEN c.expires_at <= now() THEN 'expired' ELSE 'active' END AS key_status`;
61
+ const actorForAudit = (actor: AdminActor) => ({ userId: actor.id, uid: actor.uid, provider: actor.provider, roles: actor.roles });
62
+ const validateAdmin = (actor: AdminActor, appId?: string): Result<void> => {
63
+ if (!actor.roles.includes("admin")) return fail(err.forbidden("Administrator access required"));
64
+ if (appId !== undefined) {
65
+ try {
66
+ validateAppKeyAppId(appId);
67
+ } catch {
68
+ return fail(err.badInput("Invalid or reserved app ID"));
69
+ }
70
+ }
71
+ return ok();
72
+ };
73
+ const pageParams = (params: { page?: number; perPage?: number }) => ({
74
+ page: Math.max(1, Math.trunc(params.page ?? 1)),
75
+ perPage: Math.min(500, Math.max(1, Math.trunc(params.perPage ?? 100))),
76
+ });
77
+
78
+ /** Durable DB identity, initialized transactionally by Core's migration; never cached or read from env. */
79
+ export const getInstallationId = async (db: SQL = sql): Promise<string> => {
80
+ const [row] = await db<{ id: string }[]>`SELECT id FROM auth.installation WHERE singleton = true`;
81
+ if (!row) throw new Error("Core installation identity is not initialized; run Core migrations");
82
+ return row.id;
83
+ };
84
+
85
+ export const registerAppKey = async (params: {
86
+ actor: AdminActor;
87
+ appId: string;
88
+ publicKey: string;
89
+ name?: string;
90
+ expiresAt?: string;
91
+ }): Promise<
92
+ Result<{
93
+ keyId: string;
94
+ installationId: string;
95
+ fingerprint: string;
96
+ status: AppKeyMetadata["status"];
97
+ accountStatus: AppKeyMetadata["accountStatus"];
98
+ }>
99
+ > => {
100
+ const valid = validateAdmin(params.actor, params.appId);
101
+ if (!valid.ok) return valid;
102
+ const name = params.name?.trim() ?? "App key";
103
+ if (!name || name.length > 120) return fail(err.badInput("Key name must contain 1 to 120 characters"));
104
+ let fingerprint: string;
105
+ try {
106
+ fingerprint = await appKeyFingerprint(params.publicKey);
107
+ } catch {
108
+ return fail(err.badInput("Invalid Ed25519 public key: expected a canonical 32-byte base64url point"));
109
+ }
110
+ const expiresAt = params.expiresAt ? new Date(params.expiresAt) : null;
111
+ if (expiresAt && !Number.isFinite(expiresAt.getTime())) return fail(err.badInput("Expiry must be in the future"));
112
+ const account = await serviceAccounts.getOrCreateResourceBound({
113
+ name: `${params.appId} workload`,
114
+ appId: params.appId,
115
+ resourceType: "cloud.app",
116
+ resourceId: params.appId,
117
+ createdBy: params.actor.id,
118
+ });
119
+ if (!account.ok) return account;
120
+ return sql.begin(async (db) => {
121
+ // Resource-bound accounts carry no unique index, so concurrent first enrollments can
122
+ // create more than one account for an app. One transaction lock per app serializes
123
+ // enrollment across Core replicas; keys count and land on the oldest account, the
124
+ // same one getByResource returns.
125
+ await db`SELECT pg_advisory_xact_lock(hashtextextended(${`app_key:${params.appId}`}, 0))`;
126
+ const [locked] = await db<{ id: string; status: AppKeyMetadata["accountStatus"] }[]>`
127
+ SELECT id, status FROM auth.service_accounts
128
+ WHERE kind = 'resource_bound' AND app_id = ${params.appId} AND resource_type = 'cloud.app' AND resource_id = ${params.appId}
129
+ ORDER BY created_at ASC
130
+ LIMIT 1
131
+ FOR UPDATE`;
132
+ if (!locked) return fail(err.notFound("Service account"));
133
+ const accountId = locked.id;
134
+ const accountStatus = locked.status;
135
+ const installationId = await getInstallationId(db);
136
+ const [existing] = await db<{ id: string; fingerprint: string; status: AppKeyMetadata["status"] }[]>`
137
+ SELECT c.id, c.fingerprint,
138
+ CASE WHEN c.status = 'revoked' THEN 'revoked' WHEN c.expires_at <= now() THEN 'expired' ELSE 'active' END AS status
139
+ FROM auth.service_account_credentials c
140
+ JOIN auth.service_accounts sa ON sa.id = c.service_account_id
141
+ WHERE sa.kind = 'resource_bound' AND sa.app_id = ${params.appId} AND sa.resource_type = 'cloud.app'
142
+ AND sa.resource_id = ${params.appId} AND c.kind = 'app_key' AND c.public_key = ${params.publicKey}
143
+ `;
144
+ // Revoked/expired keys remain idempotent and are never reactivated.
145
+ if (existing)
146
+ return ok({ keyId: existing.id, installationId, fingerprint: existing.fingerprint, status: existing.status, accountStatus });
147
+ if (accountStatus !== "active")
148
+ return fail({
149
+ ...err.conflict("Service account"),
150
+ message: "The app service account is disabled; enable it before enrolling a new key",
151
+ });
152
+ if (expiresAt && expiresAt.getTime() <= Date.now()) return fail(err.badInput("Expiry must be in the future"));
153
+ const [count] = await db<{ active: number }[]>`
154
+ SELECT count(*)::int AS active FROM auth.service_account_credentials c
155
+ JOIN auth.service_accounts sa ON sa.id = c.service_account_id
156
+ WHERE sa.kind = 'resource_bound' AND sa.app_id = ${params.appId} AND sa.resource_type = 'cloud.app'
157
+ AND sa.resource_id = ${params.appId} AND c.kind = 'app_key' AND c.status = 'active'
158
+ AND (c.expires_at IS NULL OR c.expires_at > now())
159
+ `;
160
+ if ((count?.active ?? 0) >= 2)
161
+ return fail({ ...err.conflict("App key"), message: "An app can have at most two active keys; revoke an old key first" });
162
+ const [row] = await db<{ id: string }[]>`
163
+ INSERT INTO auth.service_account_credentials
164
+ (service_account_id, name, kind, public_key, fingerprint, created_by, expires_at)
165
+ VALUES (${accountId}::uuid, ${name}, 'app_key', ${params.publicKey}, ${fingerprint}, ${params.actor.id}::uuid, ${expiresAt})
166
+ RETURNING id
167
+ `;
168
+ if (!row) throw new Error("App key registration returned no key");
169
+ await audit.record(
170
+ {
171
+ action: "app_key.create",
172
+ outcome: "allowed",
173
+ actor: actorForAudit(params.actor),
174
+ target: { type: "service_account_credential", id: row.id, label: name },
175
+ metadata: { appId: params.appId, serviceAccountId: accountId, fingerprint },
176
+ },
177
+ db,
178
+ );
179
+ return ok({ keyId: row.id, installationId, fingerprint, status: "active" as const, accountStatus });
180
+ });
181
+ };
182
+
183
+ export const listAppKeys = async (params: { actor: AdminActor; appId: string; page?: number; perPage?: number }) => {
184
+ const valid = validateAdmin(params.actor, params.appId);
185
+ if (!valid.ok) return valid;
186
+ const { page, perPage } = pageParams(params);
187
+ const rows = await sql<(Row & { total: number })[]>`
188
+ SELECT ${sql.unsafe(columns)}, count(*) OVER()::int AS total FROM auth.service_account_credentials c
189
+ JOIN auth.service_accounts sa ON sa.id = c.service_account_id
190
+ WHERE c.kind = 'app_key' AND sa.kind = 'resource_bound' AND sa.app_id = ${params.appId}
191
+ AND sa.resource_type = 'cloud.app' AND sa.resource_id = ${params.appId}
192
+ ORDER BY c.created_at DESC, c.id DESC LIMIT ${perPage} OFFSET ${(page - 1) * perPage}
193
+ `;
194
+ let total = rows[0]?.total ?? 0;
195
+ if (rows.length === 0 && page > 1) {
196
+ const [count] = await sql<{ total: number }[]>`
197
+ SELECT count(*)::int AS total FROM auth.service_account_credentials c
198
+ JOIN auth.service_accounts sa ON sa.id = c.service_account_id
199
+ WHERE c.kind = 'app_key' AND sa.kind = 'resource_bound' AND sa.app_id = ${params.appId}
200
+ AND sa.resource_type = 'cloud.app' AND sa.resource_id = ${params.appId}
201
+ `;
202
+ total = count?.total ?? 0;
203
+ }
204
+ return ok({ installationId: await getInstallationId(), items: rows.map(map), page, perPage, total, hasNext: page * perPage < total });
205
+ };
206
+
207
+ export const listAppsWithKeys = async (params: { actor: AdminActor; page?: number; perPage?: number }) => {
208
+ const valid = validateAdmin(params.actor);
209
+ if (!valid.ok) return valid;
210
+ const { page, perPage } = pageParams(params);
211
+ const rows = await sql<
212
+ { appId: string; accountStatus: AppKeyMetadata["accountStatus"]; totalKeys: number; activeKeys: number; total: number }[]
213
+ >`
214
+ SELECT sa.app_id AS "appId", sa.status AS "accountStatus", count(*)::int AS "totalKeys",
215
+ count(*) FILTER (WHERE c.status = 'active' AND (c.expires_at IS NULL OR c.expires_at > now()))::int AS "activeKeys",
216
+ count(*) OVER()::int AS total
217
+ FROM auth.service_account_credentials c JOIN auth.service_accounts sa ON sa.id = c.service_account_id
218
+ WHERE c.kind = 'app_key' AND sa.kind = 'resource_bound' AND sa.resource_type = 'cloud.app' AND sa.resource_id = sa.app_id
219
+ GROUP BY sa.app_id, sa.status ORDER BY sa.app_id LIMIT ${perPage} OFFSET ${(page - 1) * perPage}
220
+ `;
221
+ let total = rows[0]?.total ?? 0;
222
+ if (rows.length === 0 && page > 1) {
223
+ const [count] = await sql<{ total: number }[]>`
224
+ SELECT count(DISTINCT sa.app_id)::int AS total FROM auth.service_account_credentials c
225
+ JOIN auth.service_accounts sa ON sa.id = c.service_account_id
226
+ WHERE c.kind = 'app_key' AND sa.kind = 'resource_bound' AND sa.resource_type = 'cloud.app' AND sa.resource_id = sa.app_id
227
+ `;
228
+ total = count?.total ?? 0;
229
+ }
230
+ return ok({
231
+ installationId: await getInstallationId(),
232
+ items: rows.map(({ total: _, ...row }) => row),
233
+ page,
234
+ perPage,
235
+ total,
236
+ hasNext: page * perPage < total,
237
+ });
238
+ };
239
+
240
+ export const revokeAppKey = async (params: { actor: AdminActor; appId: string; keyId: string }): Promise<Result<void>> => {
241
+ const valid = validateAdmin(params.actor, params.appId);
242
+ if (!valid.ok) return valid;
243
+ if (params.keyId.length !== 36 || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(params.keyId))
244
+ return fail(err.notFound("App key"));
245
+ return sql.begin(async (db) => {
246
+ const [key] = await db<{ id: string; service_account_id: string; status: string; name: string; fingerprint: string }[]>`
247
+ SELECT c.id, c.service_account_id, c.status, c.name, c.fingerprint FROM auth.service_account_credentials c
248
+ JOIN auth.service_accounts sa ON sa.id = c.service_account_id
249
+ WHERE c.id = ${params.keyId}::uuid AND c.kind = 'app_key' AND sa.app_id = ${params.appId}
250
+ AND sa.kind = 'resource_bound' AND sa.resource_type = 'cloud.app' AND sa.resource_id = ${params.appId}
251
+ FOR UPDATE OF sa, c
252
+ `;
253
+ if (!key) return fail(err.notFound("App key"));
254
+ if (key.status === "revoked") return ok();
255
+ await db`UPDATE auth.service_account_credentials SET status = 'revoked', revoked_at = now(), revoked_by = ${params.actor.id}::uuid
256
+ WHERE id = ${key.id}::uuid`;
257
+ await audit.record(
258
+ {
259
+ action: "app_key.revoke",
260
+ outcome: "allowed",
261
+ actor: actorForAudit(params.actor),
262
+ target: { type: "service_account_credential", id: key.id, label: key.name },
263
+ metadata: { appId: params.appId, serviceAccountId: key.service_account_id, fingerprint: key.fingerprint },
264
+ },
265
+ db,
266
+ );
267
+ return ok();
268
+ });
269
+ };
270
+
271
+ /** Bootstrap and signed workload requests must query this directly; no cache. */
272
+ export const getActiveAppKey = async (params: { appId: string; keyId: string }, db: SQL = sql): Promise<AppKeyMetadata | null> => {
273
+ try {
274
+ validateAppKeyAppId(params.appId);
275
+ } catch {
276
+ return null;
277
+ }
278
+ if (params.keyId.length !== 36 || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(params.keyId))
279
+ return null;
280
+ const [row] = await db<Row[]>`
281
+ SELECT ${db.unsafe(columns)} FROM auth.service_account_credentials c
282
+ JOIN auth.service_accounts sa ON sa.id = c.service_account_id
283
+ WHERE c.kind = 'app_key' AND c.id = ${params.keyId}::uuid AND c.status = 'active'
284
+ AND (c.expires_at IS NULL OR c.expires_at > now()) AND sa.status = 'active'
285
+ AND sa.kind = 'resource_bound' AND sa.app_id = ${params.appId} AND sa.resource_type = 'cloud.app' AND sa.resource_id = ${params.appId}
286
+ `;
287
+ return row ? map(row) : null;
288
+ };
289
+
290
+ /** Call only after successful authentication; a late touch cannot revive a key. */
291
+ export const touchAppKey = async (params: { appId: string; keyId: string }, db: SQL = sql): Promise<void> => {
292
+ const key = await getActiveAppKey(params, db);
293
+ if (!key) return;
294
+ await db`UPDATE auth.service_account_credentials SET last_used_at = now() WHERE id = ${key.keyId}::uuid AND status = 'active'
295
+ AND (expires_at IS NULL OR expires_at > now())`;
296
+ };
297
+
298
+ export const appKeys = { register: registerAppKey, list: listAppKeys, listApps: listAppsWithKeys, revoke: revokeAppKey };
@@ -1,4 +1,5 @@
1
1
  import { lazySync } from "../_internal/process-sync";
2
+ import type { EnvSettingStatus } from "../contracts/settings-env";
2
3
  import { logger } from "./logging";
3
4
 
4
5
  /** Written by the gateway router, read by gateway-ops: one owner, one declaration. */
@@ -24,6 +25,8 @@ export type GatewayRouteWarning = {
24
25
 
25
26
  export type GatewayRouteSnapshotInput = {
26
27
  instanceId: string;
28
+ /** Sanitized migration diagnostics; omitted by pre-migration gateways. */
29
+ envSettings?: EnvSettingStatus[];
27
30
  baseUrl: string;
28
31
  startedAt: number;
29
32
  routeHash: string;
@@ -44,6 +47,8 @@ export type GatewayRouteSnapshotInput = {
44
47
 
45
48
  export type GatewayRouteSnapshot = {
46
49
  instanceId: string;
50
+ /** Sanitized migration diagnostics; omitted by pre-migration gateways. */
51
+ envSettings?: EnvSettingStatus[];
47
52
  baseUrl: string;
48
53
  startedAt: number;
49
54
  updatedAt: number;
@@ -97,6 +102,7 @@ const snapshots = lazySync((sync) =>
97
102
 
98
103
  export const buildGatewayRouteSnapshot = (input: GatewayRouteSnapshotInput): GatewayRouteSnapshot => ({
99
104
  instanceId: input.instanceId,
105
+ ...(input.envSettings ? { envSettings: input.envSettings } : {}),
100
106
  baseUrl: input.baseUrl,
101
107
  startedAt: input.startedAt,
102
108
  updatedAt: Date.now(),
@@ -1,3 +1,4 @@
1
+ export { getAppWorkloadCredential } from "../../_internal/workload-credential";
1
2
  export {
2
3
  CLOUD_INVOCATION_JWKS_PATH,
3
4
  CLOUD_INVOCATION_PROTOCOL_VERSION,
@@ -37,6 +38,7 @@ export {
37
38
  } from "./key-ring";
38
39
  export { identityMetrics } from "./metrics";
39
40
  export { isNestedSourceCurrent, type NestedInvocationLease, nestedInvocationLeases } from "./nested-invocation";
41
+ export { type ResourceReader, resolveResourceReaders } from "./resource-readers";
40
42
  export { createIdentityPublicRoutes } from "./routes";
41
43
  export {
42
44
  type CloudSessionClaims,
@@ -51,3 +53,4 @@ export {
51
53
  WORKLOAD_SCOPES,
52
54
  type WorkloadScope,
53
55
  } from "./workload-auth";
56
+ export { startAppWorkloadBroker } from "./workload-runtime";