@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,80 @@
1
+ import { sql } from "bun";
2
+ import { z } from "zod";
3
+ import { type ResourcePrincipal, ResourcePrincipalSchema } from "../../contracts/capabilities";
4
+ import type { AccessSubject } from "../../contracts/shared";
5
+ import { decodeAccountCategoryEnabled } from "../account-category-policy";
6
+ import { toPgUuidArray } from "../postgres";
7
+ import type { ServiceAccount } from "../service-accounts";
8
+
9
+ /** Account authority only: no session, credential scopes, or content authority. */
10
+ export type ResourceReader = {
11
+ principal: ResourcePrincipal;
12
+ subject: AccessSubject;
13
+ binding: Pick<ServiceAccount, "appId" | "resourceType" | "resourceId"> | null;
14
+ };
15
+
16
+ type UserRow = { id: string; category_enabled: string | null };
17
+ type AccountRow = {
18
+ id: string;
19
+ kind: ServiceAccount["kind"];
20
+ delegated_user_id: string | null;
21
+ app_id: string | null;
22
+ resource_type: string | null;
23
+ resource_id: string | null;
24
+ };
25
+
26
+ /** Resolve at most 500 current accounts, including delegated users and account-category policy.
27
+ * Grants and resource bindings remain the resource provider's responsibility.
28
+ */
29
+ export const resolveResourceReaders = async (principals: readonly ResourcePrincipal[]): Promise<ResourceReader[]> => {
30
+ const input = z.array(ResourcePrincipalSchema).max(500).parse(principals);
31
+ if (!input.length) return [];
32
+ const userIds = input.flatMap((principal) => (principal.type === "user" ? [principal.userId] : []));
33
+ const accountIds = input.flatMap((principal) => (principal.type === "service_account" ? [principal.serviceAccountId] : []));
34
+ const accounts = await sql<AccountRow[]>`
35
+ SELECT id, kind, delegated_user_id, app_id, resource_type, resource_id
36
+ FROM auth.service_accounts WHERE id = ANY(${toPgUuidArray(accountIds)}::uuid[]) AND status = 'active'
37
+ `;
38
+ const users = await sql<UserRow[]>`
39
+ SELECT u.id, category.value AS category_enabled FROM auth.users u
40
+ LEFT JOIN settings.entries category ON category.key = 'user.category.' ||
41
+ CASE WHEN u.provider = 'ipa' THEN 'freeipa' WHEN u.profile = 'guest' THEN 'guest' ELSE 'login' END || '.enabled'
42
+ WHERE u.id = ANY(${toPgUuidArray([...userIds, ...accounts.flatMap((account) => (account.delegated_user_id ? [account.delegated_user_id] : []))])}::uuid[])
43
+ AND (u.account_expires IS NULL OR u.account_expires > clock_timestamp())
44
+ `;
45
+ const enabled = new Map<string | null, boolean>();
46
+ const allowedUsers = new Set<string>();
47
+ for (const user of users) {
48
+ if (!enabled.has(user.category_enabled)) enabled.set(user.category_enabled, await decodeAccountCategoryEnabled(user.category_enabled));
49
+ if (enabled.get(user.category_enabled)) allowedUsers.add(user.id);
50
+ }
51
+ const accountsById = new Map(accounts.map((account) => [account.id, account]));
52
+ return input.flatMap((principal): ResourceReader[] => {
53
+ if (principal.type === "user") {
54
+ const userId = principal.userId.toLowerCase();
55
+ return allowedUsers.has(userId) ? [{ principal, subject: { type: "user", userId }, binding: null }] : [];
56
+ }
57
+ const account = accountsById.get(principal.serviceAccountId.toLowerCase());
58
+ if (!account) return [];
59
+ if (account.kind === "user_delegated")
60
+ return account.delegated_user_id && allowedUsers.has(account.delegated_user_id)
61
+ ? [
62
+ {
63
+ principal,
64
+ subject: { type: "user", userId: account.delegated_user_id, delegatedByServiceAccountId: account.id },
65
+ binding: null,
66
+ },
67
+ ]
68
+ : [];
69
+ return [
70
+ {
71
+ principal,
72
+ subject: { type: "service_account", serviceAccountId: account.id },
73
+ binding:
74
+ account.kind === "resource_bound"
75
+ ? { appId: account.app_id, resourceType: account.resource_type, resourceId: account.resource_id }
76
+ : null,
77
+ },
78
+ ];
79
+ });
80
+ };
@@ -52,20 +52,28 @@ export const readIdentityGroupsAdmin = async (db: SQL): Promise<readonly string[
52
52
  return Object.freeze(normalizeGroups(groups));
53
53
  };
54
54
 
55
- const resolveCoreJwksUrl = (issuer: string, path: string, transportOrigin = env.CLOUD_IDENTITY_JWKS_ORIGIN): URL => {
55
+ const resolveCoreJwksUrl = (
56
+ issuer: string,
57
+ path: string,
58
+ transportOrigin = env.CLOUD_IDENTITY_JWKS_ORIGIN ?? env.CLOUD_CORE_INTERNAL_ORIGIN,
59
+ ): URL => {
56
60
  if (!transportOrigin) return new URL(path, issuer);
57
61
  const origin = new URL(transportOrigin);
58
62
  if (origin.protocol !== "http:" && origin.protocol !== "https:") {
59
- throw new Error("CLOUD_IDENTITY_JWKS_ORIGIN must use http or https");
63
+ throw new Error("Core JWKS origin (CLOUD_CORE_INTERNAL_ORIGIN or legacy CLOUD_IDENTITY_JWKS_ORIGIN) must use http or https");
60
64
  }
61
65
  return new URL(path, origin);
62
66
  };
63
67
 
64
- export const resolveSessionJwksUrl = (issuer: string, transportOrigin = env.CLOUD_IDENTITY_JWKS_ORIGIN): URL =>
65
- resolveCoreJwksUrl(issuer, CLOUD_SESSION_JWKS_PATH, transportOrigin);
68
+ export const resolveSessionJwksUrl = (
69
+ issuer: string,
70
+ transportOrigin = env.CLOUD_IDENTITY_JWKS_ORIGIN ?? env.CLOUD_CORE_INTERNAL_ORIGIN,
71
+ ): URL => resolveCoreJwksUrl(issuer, CLOUD_SESSION_JWKS_PATH, transportOrigin);
66
72
 
67
- export const resolveInvocationJwksUrl = (issuer: string, transportOrigin = env.CLOUD_IDENTITY_JWKS_ORIGIN): URL =>
68
- resolveCoreJwksUrl(issuer, CLOUD_INVOCATION_JWKS_PATH, transportOrigin);
73
+ export const resolveInvocationJwksUrl = (
74
+ issuer: string,
75
+ transportOrigin = env.CLOUD_IDENTITY_JWKS_ORIGIN ?? env.CLOUD_CORE_INTERNAL_ORIGIN,
76
+ ): URL => resolveCoreJwksUrl(issuer, CLOUD_INVOCATION_JWKS_PATH, transportOrigin);
69
77
 
70
78
  export const resolveOAuthJwksUrl = (issuer: string, transportOrigin = env.CLOUD_OAUTH_JWKS_ORIGIN): URL => {
71
79
  if (!transportOrigin) return new URL(CLOUD_OAUTH_JWKS_PATH, issuer);
@@ -0,0 +1,253 @@
1
+ /** Core-only workload issuer. This module never reads identity signing keys, OAuth secrets, or sessions. */
2
+ import type { NatsConnection } from "@nats-io/transport-node";
3
+ import { sql } from "bun";
4
+ import { getProcessNats } from "../../_internal/process-sync";
5
+ import { APP_REGISTRY_TTL_MS, appInstancePrefix, appInstanceRegistry } from "../../_internal/registry";
6
+ import {
7
+ WORKLOAD_CHECK_INTERVAL_MS,
8
+ WORKLOAD_LIFETIME_MS,
9
+ WORKLOAD_ROTATE_BEFORE_MS,
10
+ type WorkloadRequest,
11
+ WorkloadRequestSchema,
12
+ type WorkloadResponse,
13
+ workloadSubject,
14
+ } from "../../_internal/workload-protocol";
15
+ import { env } from "../../config/env";
16
+ import { audit } from "../audit";
17
+ import { logger } from "../logging";
18
+ import { serviceAccountCredentials } from "../service-account-credentials";
19
+ import { serviceAccounts } from "../service-accounts";
20
+
21
+ const isRegistered = async (appId: string, instanceId: string): Promise<boolean> => {
22
+ const key = `${appInstancePrefix(appId)}${instanceId}`;
23
+ const snapshot = await appInstanceRegistry().snapshot({ prefix: key });
24
+ return snapshot.entries.some((entry) => entry.key === key && entry.value.appId === appId && entry.value.instanceId === instanceId);
25
+ };
26
+
27
+ export const issueAppWorkloadCredential = async (
28
+ request: WorkloadRequest,
29
+ registered: (appId: string, instanceId: string) => Promise<boolean> = isRegistered,
30
+ ): Promise<WorkloadResponse> => {
31
+ if (!(await registered(request.appId, request.instanceId))) return { status: "unregistered" };
32
+ const account = await serviceAccounts.getOrCreateResourceBound({
33
+ name: `${request.appId} workload`,
34
+ appId: request.appId,
35
+ resourceType: "cloud.app",
36
+ resourceId: request.appId,
37
+ });
38
+ if (!account.ok) return { status: "unavailable" };
39
+ return sql.begin(async (db) => {
40
+ // One account lock serializes issuance/rotation/removal across Core replicas. Admin revocation
41
+ // locks the credential row too, so it cannot be overwritten by an in-flight refresh.
42
+ const [owner] = await db<{ status: string }[]>`SELECT status FROM auth.service_accounts WHERE id = ${account.data.id}::uuid FOR UPDATE`;
43
+ if (owner?.status !== "active") return { status: "revoked" };
44
+ const [current] = await db<{ id: string; status: string; expires_at: Date; previous_credential_id: string | null }[]>`
45
+ SELECT c.id, c.status, c.expires_at, i.previous_credential_id FROM auth.app_workload_instances i
46
+ JOIN auth.service_account_credentials c ON c.id = i.credential_id
47
+ WHERE i.app_id = ${request.appId} AND i.instance_id = ${request.instanceId}
48
+ FOR UPDATE OF c
49
+ `;
50
+ if (request.operation === "release") {
51
+ if (current?.status === "active") {
52
+ for (const credentialId of [current.id, current.previous_credential_id]) {
53
+ if (!credentialId) continue;
54
+ const revoked = await serviceAccountCredentials.revoke(
55
+ { credentialId, system: { service: "app-workload", reason: "instance stopped" } },
56
+ { db },
57
+ );
58
+ if (!revoked.ok) throw new Error("Stopped workload credential could not be revoked");
59
+ }
60
+ await db`DELETE FROM auth.app_workload_instances WHERE app_id = ${request.appId} AND instance_id = ${request.instanceId}`;
61
+ await db`DELETE FROM auth.service_account_credentials WHERE id = ${current.id}::uuid OR id = ${current.previous_credential_id}::uuid`;
62
+ }
63
+ return { status: "revoked" };
64
+ }
65
+ if (!(await registered(request.appId, request.instanceId))) return { status: "unregistered" };
66
+ await db`UPDATE auth.app_workload_instances SET last_refreshed_at = now()
67
+ WHERE app_id = ${request.appId} AND instance_id = ${request.instanceId}`;
68
+ if (
69
+ current?.status === "active" &&
70
+ current.id === request.credentialId &&
71
+ current.expires_at.getTime() - Date.now() > WORKLOAD_ROTATE_BEFORE_MS
72
+ ) {
73
+ return { status: "current", credentialId: current.id };
74
+ }
75
+ const expiresAt = new Date(Date.now() + WORKLOAD_LIFETIME_MS).toISOString();
76
+ const issued = await serviceAccountCredentials.createApiToken(
77
+ {
78
+ serviceAccountId: account.data.id,
79
+ name: `Runtime ${request.instanceId}`,
80
+ scopes: ["identity:invoke"],
81
+ expiresAt,
82
+ },
83
+ { db },
84
+ );
85
+ if (!issued.ok) return { status: "unavailable" };
86
+ // An acknowledged current token overlaps its successor until expiry. When a reply was
87
+ // lost, replace only the undelivered generation and keep the token the caller still knows.
88
+ const retainedId = current
89
+ ? request.credentialId === current.id
90
+ ? current.id
91
+ : current.previous_credential_id === request.credentialId
92
+ ? current.previous_credential_id
93
+ : null
94
+ : null;
95
+ for (const retiredId of [current?.id, current?.previous_credential_id]) {
96
+ if (!retiredId || retiredId === retainedId) continue;
97
+ const revoked = await serviceAccountCredentials.revoke(
98
+ { credentialId: retiredId, system: { service: "app-workload", reason: "rotation" } },
99
+ { db },
100
+ );
101
+ if (!revoked.ok) throw new Error("Workload rotation could not retire previous credential");
102
+ }
103
+ await db`
104
+ INSERT INTO auth.app_workload_instances (app_id, instance_id, credential_id, previous_credential_id)
105
+ VALUES (${request.appId}, ${request.instanceId}, ${issued.data.credential.id}::uuid, ${retainedId}::uuid)
106
+ ON CONFLICT (app_id, instance_id) DO UPDATE
107
+ SET credential_id = EXCLUDED.credential_id, previous_credential_id = EXCLUDED.previous_credential_id, last_refreshed_at = now()
108
+ `;
109
+ for (const retiredId of [current?.id, current?.previous_credential_id]) {
110
+ if (retiredId && retiredId !== retainedId) await db`DELETE FROM auth.service_account_credentials WHERE id = ${retiredId}::uuid`;
111
+ }
112
+ await audit.record(
113
+ {
114
+ action: "service_account_credential.create",
115
+ outcome: "allowed",
116
+ target: { type: "service_account_credential", id: issued.data.credential.id, label: issued.data.credential.name },
117
+ metadata: {
118
+ provenance: "system",
119
+ service: "app-workload",
120
+ appId: request.appId,
121
+ instanceId: request.instanceId,
122
+ serviceAccountId: account.data.id,
123
+ },
124
+ },
125
+ db,
126
+ );
127
+ // Deliberately construct the response: only a newly minted app-bound token and its coordinates.
128
+ return {
129
+ status: "issued",
130
+ appId: request.appId,
131
+ instanceId: request.instanceId,
132
+ credentialId: issued.data.credential.id,
133
+ token: issued.data.token,
134
+ expiresAt,
135
+ };
136
+ });
137
+ };
138
+
139
+ /** Revoke disappeared instances, including TTL expiry after an ungraceful stop. Pages bound memory. */
140
+ export const revokeRemovedAppWorkloads = async (
141
+ registered: (appId: string, instanceId: string) => Promise<boolean> = isRegistered,
142
+ ): Promise<void> => {
143
+ let afterApp = "";
144
+ let afterInstance = "";
145
+ for (;;) {
146
+ // Same page size as the credential administration API; never materialize credential history.
147
+ const rows = await sql<{ app_id: string; instance_id: string; credential_id: string; service_account_id: string }[]>`
148
+ SELECT i.app_id, i.instance_id, i.credential_id, c.service_account_id
149
+ FROM auth.app_workload_instances i JOIN auth.service_account_credentials c ON c.id = i.credential_id
150
+ WHERE c.status = 'active' AND i.last_refreshed_at < ${new Date(Date.now() - APP_REGISTRY_TTL_MS)} AND (i.app_id, i.instance_id) > (${afterApp}, ${afterInstance})
151
+ ORDER BY i.app_id, i.instance_id LIMIT 100
152
+ `;
153
+ if (!rows.length) return;
154
+ for (const row of rows) {
155
+ afterApp = row.app_id;
156
+ afterInstance = row.instance_id;
157
+ if (await registered(row.app_id, row.instance_id)) continue;
158
+ await sql.begin(async (db) => {
159
+ await db`SELECT id FROM auth.service_accounts WHERE id = ${row.service_account_id}::uuid FOR UPDATE`;
160
+ if (await registered(row.app_id, row.instance_id)) return;
161
+ // Read again under the account lock, in case another Core replica rotated it meanwhile.
162
+ const [current] = await db<
163
+ { credential_id: string; previous_credential_id: string | null; status: string; last_refreshed_at: Date }[]
164
+ >`
165
+ SELECT i.credential_id, i.previous_credential_id, c.status, i.last_refreshed_at FROM auth.app_workload_instances i
166
+ JOIN auth.service_account_credentials c ON c.id = i.credential_id
167
+ WHERE i.app_id = ${row.app_id} AND i.instance_id = ${row.instance_id}
168
+ FOR UPDATE OF c
169
+ `;
170
+ if (current?.status === "active" && current.last_refreshed_at.getTime() < Date.now() - APP_REGISTRY_TTL_MS) {
171
+ for (const credentialId of [current.credential_id, current.previous_credential_id]) {
172
+ if (!credentialId) continue;
173
+ const revoked = await serviceAccountCredentials.revoke(
174
+ { credentialId, system: { service: "app-workload", reason: "instance removed" } },
175
+ { db },
176
+ );
177
+ if (!revoked.ok) throw new Error("Removed workload credential could not be revoked");
178
+ }
179
+ // An automatically retired instance may register again after a registry reset. An
180
+ // admin-revoked credential keeps its mapping and disabled account until explicit recovery.
181
+ await db`DELETE FROM auth.app_workload_instances WHERE app_id = ${row.app_id} AND instance_id = ${row.instance_id}`;
182
+ await db`DELETE FROM auth.service_account_credentials WHERE id = ${current.credential_id}::uuid OR id = ${current.previous_credential_id}::uuid`;
183
+ }
184
+ });
185
+ }
186
+ }
187
+ };
188
+
189
+ export const startAppWorkloadBroker = async (
190
+ options: {
191
+ connection?: NatsConnection;
192
+ namespace?: string;
193
+ issue?: (request: WorkloadRequest) => Promise<WorkloadResponse>;
194
+ reconcile?: () => Promise<void>;
195
+ } = {},
196
+ ): Promise<() => Promise<void>> => {
197
+ const connection = options.connection ?? getProcessNats();
198
+ const issue = options.issue ?? issueAppWorkloadCredential;
199
+ const reconcile = options.reconcile ?? revokeRemovedAppWorkloads;
200
+ const log = logger("app-workload");
201
+ // Issuance gets four concurrent DB transactions per Core replica, reserving the rest of
202
+ // Bun SQL's default ten-connection pool for identity requests. Overload retries at the caller.
203
+ const inFlight = new Set<Promise<void>>();
204
+ let stopped = false;
205
+ let maintenance: Promise<void> | undefined;
206
+ let timer: Timer | undefined;
207
+ const subscription = connection.subscribe(workloadSubject(options.namespace ?? env.SYNC_NAMESPACE), {
208
+ queue: "core-workload-v1",
209
+ callback: (error, message) => {
210
+ if (error || !message.reply) return;
211
+ // App id, instance id and UUID fit in 1 KiB including JSON escaping. Reject larger input before parsing.
212
+ if (stopped || inFlight.size >= 4 || message.data.length > 1024) {
213
+ message.respond(JSON.stringify({ status: "unavailable" }));
214
+ return;
215
+ }
216
+ const operation = (async () => {
217
+ let response: WorkloadResponse = { status: "unavailable" };
218
+ try {
219
+ const parsed = WorkloadRequestSchema.safeParse(message.json<unknown>());
220
+ if (parsed.success) response = await issue(parsed.data);
221
+ } catch {
222
+ log.error("Workload credential issuance failed; caller will retry");
223
+ }
224
+ if (!stopped) message.respond(JSON.stringify(response));
225
+ })();
226
+ inFlight.add(operation);
227
+ void operation.finally(() => inFlight.delete(operation));
228
+ },
229
+ });
230
+ const maintain = () => {
231
+ maintenance = reconcile()
232
+ .catch(() => {
233
+ log.error("Workload removal reconciliation failed; retrying");
234
+ })
235
+ .finally(() => {
236
+ if (!stopped) timer = setTimeout(maintain, WORKLOAD_CHECK_INTERVAL_MS);
237
+ });
238
+ };
239
+ const stop = async () => {
240
+ stopped = true;
241
+ clearTimeout(timer);
242
+ subscription.unsubscribe();
243
+ await Promise.allSettled([...inFlight, ...(maintenance ? [maintenance] : [])]);
244
+ };
245
+ maintain();
246
+ try {
247
+ await connection.flush();
248
+ } catch (error) {
249
+ await stop();
250
+ throw error;
251
+ }
252
+ return stop;
253
+ };
@@ -8,6 +8,7 @@ export type { PosixCandidate } from "./accounts/posix";
8
8
  export { accountIdentities, AccountIdentityError } from "./accounts/identities";
9
9
  export type { AccountIdentityUser, AccountIdentityGroup, AccountIdentityPage, AccountIdentityAvailability } from "./accounts/identities";
10
10
  export type { AccountIdentityReconciliation } from "./accounts/identity-reconciliation";
11
+ export { getAppWorkloadCredential } from "../_internal/workload-credential";
11
12
  export { providers } from "./providers";
12
13
  export { createAllowedNetworkFetch } from "./allowed-network-fetch";
13
14
  export { requestPublicHttps, type PublicHttpInput } from "./public-http";
@@ -281,7 +282,9 @@ export {
281
282
  type AppAccessSnapshot,
282
283
  } from "./app-access-management";
283
284
 
284
- export { storage } from "./object-store";
285
+ import { storage as platformStorage } from "./object-store";
286
+ /** @deprecated Import storage from @k2b/cloud. */
287
+ export const storage = platformStorage;
285
288
  export type {
286
289
  ObjectStoreFile,
287
290
  ObjectVisibility,
@@ -1,8 +1,9 @@
1
- import { env } from "../../config/env";
2
1
  import { networkAddressFamily, normalizeNetworkHostname, parseNetworkAllowlistEnv } from "../../shared/network-address";
3
2
  import { NetworkTargetBlockedError, resolveAllowedNetworkAddresses } from "../network-security";
3
+ import { coreSettings } from "../settings/api";
4
4
 
5
- export const outgoingMailAllowlist = () => parseNetworkAllowlistEnv("OUTGOING_MAIL_ALLOWED_TARGETS", env.OUTGOING_MAIL_ALLOWED_TARGETS);
5
+ export const outgoingMailAllowlist = async () =>
6
+ parseNetworkAllowlistEnv("outgoing_mail.allowed_targets", await coreSettings.get<string[]>("outgoing_mail.allowed_targets"));
6
7
 
7
8
  /** One bounded lookup shared by SMTP and bounce IMAP; clients use only the checked addresses. */
8
9
  export const resolveMailHost = async (host: string, signal?: AbortSignal, resolve = resolveAllowedNetworkAddresses, port?: number) => {
@@ -14,7 +15,7 @@ export const resolveMailHost = async (host: string, signal?: AbortSignal, resolv
14
15
  try {
15
16
  cancellation.throwIfAborted();
16
17
  const addresses = await Promise.race([
17
- resolve(hostname, outgoingMailAllowlist(), undefined, port),
18
+ outgoingMailAllowlist().then((allowlist) => resolve(hostname, allowlist, undefined, port)),
18
19
  new Promise<never>((_, reject) => {
19
20
  abort = () => reject(cancellation.reason);
20
21
  cancellation.addEventListener("abort", abort, { once: true });
@@ -29,7 +30,7 @@ export const resolveMailHost = async (host: string, signal?: AbortSignal, resolv
29
30
  } catch (error) {
30
31
  if (error instanceof NetworkTargetBlockedError)
31
32
  throw new NetworkTargetBlockedError(
32
- "Outgoing mail target is on an internal network. An operator can allow it with OUTGOING_MAIL_ALLOWED_TARGETS.",
33
+ "Outgoing mail target is on an internal network. An operator can allow it with outgoing_mail.allowed_targets.",
33
34
  );
34
35
  throw error;
35
36
  } finally {
@@ -221,10 +221,10 @@ const verifyApiTokenSecret = async (secret: string, storedHash: string, scopes:
221
221
  }
222
222
  };
223
223
 
224
- const generateUniqueTokenParts = async (): Promise<{ tokenPrefix: string; secret: string; token: string }> => {
224
+ const generateUniqueTokenParts = async (db: SqlRunner): Promise<{ tokenPrefix: string; secret: string; token: string }> => {
225
225
  for (let i = 0; i < 5; i += 1) {
226
226
  const parts = generateTokenParts();
227
- const [row] = await sql<{ exists: boolean }[]>`
227
+ const [row] = await db<{ exists: boolean }[]>`
228
228
  SELECT EXISTS(
229
229
  SELECT 1 FROM auth.service_account_credentials WHERE token_prefix = ${parts.tokenPrefix}
230
230
  ) AS exists
@@ -342,7 +342,7 @@ const insertApiToken = async (
342
342
  if (expiresAt.getTime() <= Date.now()) return fail(err.badInput("Expiry must be in the future"));
343
343
  }
344
344
 
345
- const parts = await generateUniqueTokenParts();
345
+ const parts = await generateUniqueTokenParts(db);
346
346
  const secretHash = hashApiTokenSecret(parts.secret, params.scopes ?? []);
347
347
 
348
348
  try {
@@ -376,15 +376,18 @@ const insertApiToken = async (
376
376
  }
377
377
  };
378
378
 
379
- export const createApiToken = async (params: {
380
- serviceAccountId: string;
381
- name: string;
382
- createdBy?: string | null;
383
- expiresAt?: string | null;
384
- scopes?: string[];
385
- }): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
379
+ export const createApiToken = async (
380
+ params: {
381
+ serviceAccountId: string;
382
+ name: string;
383
+ createdBy?: string | null;
384
+ expiresAt?: string | null;
385
+ scopes?: string[];
386
+ },
387
+ options: { db?: SQL } = {},
388
+ ): Promise<Result<{ credential: ServiceAccountCredential; token: string }>> => {
386
389
  if (params.scopes?.includes(AI_INFERENCE_SCOPE)) return fail(err.badInput("ai:inference is reserved for models-only personal API keys"));
387
- return insertApiToken(sql, params);
390
+ return insertApiToken(options.db ?? sql, params);
388
391
  };
389
392
 
390
393
  export const createUserApiToken = async (params: {
@@ -531,7 +534,7 @@ export const listForDelegatedUser = async (params: { userId: string }): Promise<
531
534
  c.last_used_at, c.created_by, c.created_at, c.revoked_at, c.revoked_by
532
535
  FROM auth.service_account_credentials c
533
536
  JOIN auth.service_accounts sa ON sa.id = c.service_account_id
534
- WHERE sa.kind = 'user_delegated'
537
+ WHERE c.kind = 'api_token' AND sa.kind = 'user_delegated'
535
538
  AND sa.delegated_user_id = ${params.userId}::uuid
536
539
  AND c.status = 'active'
537
540
  ORDER BY c.created_at DESC
@@ -569,7 +572,7 @@ export const listOverview = async (config?: {
569
572
  FROM auth.service_account_credentials c
570
573
  JOIN auth.service_accounts sa ON sa.id = c.service_account_id
571
574
  LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
572
- WHERE (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
575
+ WHERE c.kind = 'api_token' AND (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
573
576
  AND (${credentialStatus}::text IS NULL OR c.status = ${credentialStatus})
574
577
  AND (${userId}::uuid IS NULL OR sa.delegated_user_id = ${userId}::uuid)
575
578
  AND (${serviceAccountId}::uuid IS NULL OR sa.id = ${serviceAccountId}::uuid)
@@ -621,7 +624,7 @@ export const listOverview = async (config?: {
621
624
  FROM auth.service_account_credentials c
622
625
  JOIN auth.service_accounts sa ON sa.id = c.service_account_id
623
626
  LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
624
- WHERE (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
627
+ WHERE c.kind = 'api_token' AND (${serviceAccountKind}::text IS NULL OR sa.kind = ${serviceAccountKind})
625
628
  AND (${credentialStatus}::text IS NULL OR c.status = ${credentialStatus})
626
629
  AND (${userId}::uuid IS NULL OR sa.delegated_user_id = ${userId}::uuid)
627
630
  AND (${serviceAccountId}::uuid IS NULL OR sa.id = ${serviceAccountId}::uuid)
@@ -688,7 +691,7 @@ export const getOverview = async (params: { id: string }): Promise<ServiceAccoun
688
691
  FROM auth.service_account_credentials c
689
692
  JOIN auth.service_accounts sa ON sa.id = c.service_account_id
690
693
  LEFT JOIN auth.users du ON du.id = sa.delegated_user_id
691
- WHERE c.id = ${params.id}::uuid
694
+ WHERE c.kind = 'api_token' AND c.id = ${params.id}::uuid
692
695
  LIMIT 1
693
696
  `;
694
697
  return row ? mapCredentialOverview(row) : null;
@@ -732,14 +735,14 @@ type RevokeCredentialInput =
732
735
  export const revoke = async (params: RevokeCredentialInput, options: { db?: SQL } = {}): Promise<Result<void>> => {
733
736
  if (!UUID_PATTERN.test(params.credentialId)) return fail(err.notFound("API key"));
734
737
 
735
- const run = async (db: SQL): Promise<Result<void>> => {
738
+ const run = async (db: SQL, credentialId = params.credentialId): Promise<Result<void>> => {
736
739
  const [row] = await db<DbCredentialRow[]>`
737
740
  UPDATE auth.service_account_credentials
738
741
  SET status = 'revoked',
739
742
  revoked_at = now(),
740
743
  revoked_by = ${params.actor?.id ?? null}::uuid
741
- WHERE id = ${params.credentialId}::uuid
742
- AND status = 'active'
744
+ WHERE id = ${credentialId}::uuid
745
+ AND status = 'active' AND kind = 'api_token'
743
746
  RETURNING id, service_account_id, name, kind, status, token_prefix, scopes, expires_at,
744
747
  last_used_at, created_by, created_at, revoked_at, revoked_by
745
748
  `;
@@ -750,14 +753,14 @@ export const revoke = async (params: RevokeCredentialInput, options: { db?: SQL
750
753
  SELECT id, service_account_id, name, kind, status, token_prefix, scopes, expires_at,
751
754
  last_used_at, created_by, created_at, revoked_at, revoked_by
752
755
  FROM auth.service_account_credentials
753
- WHERE id = ${params.credentialId}::uuid
756
+ WHERE id = ${credentialId}::uuid AND kind = 'api_token'
754
757
  LIMIT 1
755
758
  `;
756
759
  const result = row || existing?.status === "revoked" ? ok() : fail(err.notFound("API key"));
757
760
  return audit.recordResult({
758
761
  action: "service_account_credential.revoke",
759
762
  actor: params.actor ? actorForUser(params.actor) : null,
760
- target: { type: "service_account_credential", id: params.credentialId, label: row?.name ?? existing?.name ?? null },
763
+ target: { type: "service_account_credential", id: credentialId, label: row?.name ?? existing?.name ?? null },
761
764
  metadata: {
762
765
  serviceAccountId: row?.service_account_id ?? existing?.service_account_id ?? null,
763
766
  ...(params.actor ? { adminAction: true } : { provenance: "system", service: params.system.service, reason: params.system.reason }),
@@ -766,7 +769,46 @@ export const revoke = async (params: RevokeCredentialInput, options: { db?: SQL
766
769
  db,
767
770
  });
768
771
  };
769
- return options.db ? run(options.db) : sql.begin(run);
772
+ const revokeWithWorkload = async (db: SQL): Promise<Result<void>> => {
773
+ if (params.actor) {
774
+ const [instance] = await db<{ app_id: string; instance_id: string; service_account_id: string }[]>`
775
+ SELECT i.app_id, i.instance_id, c.service_account_id FROM auth.app_workload_instances i
776
+ JOIN auth.service_account_credentials c ON c.id = i.credential_id
777
+ WHERE i.credential_id = ${params.credentialId}::uuid OR i.previous_credential_id = ${params.credentialId}::uuid
778
+ `;
779
+ if (instance) {
780
+ // Same lock order as Core rotation. Resolve the latest generation *after* waiting,
781
+ // so an admin revoking the displayed token also revokes an overlapping rotation.
782
+ await db`SELECT id FROM auth.service_accounts WHERE id = ${instance.service_account_id}::uuid FOR UPDATE`;
783
+ // The account is the durable app-wide off switch, including future process instances.
784
+ await db`UPDATE auth.service_accounts SET status = 'disabled' WHERE id = ${instance.service_account_id}::uuid`;
785
+ await audit.record(
786
+ {
787
+ action: "service_account.disable",
788
+ outcome: "allowed",
789
+ actor: actorForUser(params.actor),
790
+ target: { type: "service_account", id: instance.service_account_id },
791
+ metadata: { appId: instance.app_id, serviceAccountId: instance.service_account_id, status: "disabled" },
792
+ },
793
+ db,
794
+ );
795
+ const [current] = await db<{ credential_id: string; previous_credential_id: string | null }[]>`
796
+ SELECT credential_id, previous_credential_id FROM auth.app_workload_instances
797
+ WHERE app_id = ${instance.app_id} AND instance_id = ${instance.instance_id}
798
+ `;
799
+ if (current) {
800
+ for (const credentialId of [current.credential_id, current.previous_credential_id]) {
801
+ if (!credentialId) continue;
802
+ const result = await run(db, credentialId);
803
+ if (!result.ok) return result;
804
+ }
805
+ return ok();
806
+ }
807
+ }
808
+ }
809
+ return run(db);
810
+ };
811
+ return options.db ? revokeWithWorkload(options.db) : sql.begin(revokeWithWorkload);
770
812
  };
771
813
 
772
814
  const findActiveByTokenPrefix = async (tokenPrefix: string): Promise<DbCredentialWithSecretRow | null> => {
@@ -27,7 +27,7 @@ import { deleteKey, readKey, readKeyState, type StoredSettingState, writeKey } f
27
27
  */
28
28
  export interface SettingsAPI<F extends Record<string, unknown>> {
29
29
  get<K extends keyof F & string>(key: K): Promise<F[K]>;
30
- read<K extends keyof F & string>(key: K): Promise<{ value: F[K]; stored: StoredSettingState }>;
30
+ read<K extends keyof F & string>(key: K): Promise<{ value: F[K]; stored: StoredSettingState; bindingMismatch?: true }>;
31
31
  set<K extends keyof F & string>(key: K, value: F[K], options?: { replaceUnreadable?: boolean }): Promise<void>;
32
32
  remove<K extends keyof F & string>(key: K): Promise<void>;
33
33
  }
@@ -59,8 +59,8 @@ export const createSettingsAPI = <S extends AppSettingsMap>(): SettingsAPI<{
59
59
  */
60
60
  export const coreSettings = {
61
61
  get: <T = unknown>(key: string): Promise<T> => readKey(key) as Promise<T>,
62
- read: <T = unknown>(key: string): Promise<{ value: T; stored: StoredSettingState }> =>
63
- readKeyState(key) as Promise<{ value: T; stored: StoredSettingState }>,
62
+ read: <T = unknown>(key: string): Promise<{ value: T; stored: StoredSettingState; bindingMismatch?: true }> =>
63
+ readKeyState(key) as Promise<{ value: T; stored: StoredSettingState; bindingMismatch?: true }>,
64
64
  set: (key: string, value: unknown, options?: { replaceUnreadable?: boolean }): Promise<void> => writeKey(key, value, undefined, options),
65
65
  remove: (key: string): Promise<void> => deleteKey(key),
66
66
  };
@@ -0,0 +1,45 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { SETTINGS, SETTINGS_MAP } from "./defaults";
4
+
5
+ const envelope = z.object({ value: z.unknown(), bindings: z.record(z.string(), z.string()) });
6
+ const hash = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
7
+ export const settingOwners = (key: string) => SETTINGS.filter((def) => def.resetOnChange?.includes(key));
8
+
9
+ /** Bind the value inside its encrypted row; no independently writable marker. */
10
+ export const bindSettingValue = async (key: string, value: unknown, resolve: (key: string) => Promise<unknown>): Promise<unknown> => {
11
+ const owners = settingOwners(key);
12
+ if (!owners.length) return value;
13
+ const bindings: Record<string, string> = {};
14
+ for (const owner of owners) bindings[owner.key] = hash(await resolve(owner.key));
15
+ return { value, bindings };
16
+ };
17
+
18
+ export const unwrapSettingValue = (key: string, raw: unknown): unknown => {
19
+ if (!settingOwners(key).length) return raw;
20
+ const parsed = envelope.safeParse(raw);
21
+ return parsed.success ? parsed.data.value : raw;
22
+ };
23
+
24
+ export const replaceSettingValue = (key: string, raw: unknown, value: unknown): unknown => {
25
+ if (!settingOwners(key).length) return value;
26
+ const parsed = envelope.safeParse(raw);
27
+ return parsed.success ? { ...parsed.data, value } : value;
28
+ };
29
+
30
+ /** Missing or stale bindings require a new value, including legacy unbound rows. */
31
+ export const resolveBoundSetting = async (
32
+ key: string,
33
+ raw: unknown,
34
+ resolve: (key: string) => Promise<unknown>,
35
+ ): Promise<{ value: unknown; mismatch: boolean }> => {
36
+ const owners = settingOwners(key);
37
+ const fallback = SETTINGS_MAP.get(key)?.default;
38
+ if (!owners.length) return { value: raw, mismatch: false };
39
+ const parsed = envelope.safeParse(raw);
40
+ if (!parsed.success) return { value: fallback, mismatch: true };
41
+ for (const owner of owners) {
42
+ if (parsed.data.bindings[owner.key] !== hash(await resolve(owner.key))) return { value: fallback, mismatch: true };
43
+ }
44
+ return { value: parsed.data.value, mismatch: false };
45
+ };