@k2b/cloud 0.45.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 (131) hide show
  1. package/package.json +11 -4
  2. package/scripts/build.ts +5 -1
  3. package/scripts/preload.ts +5 -1
  4. package/src/_internal/app-queries.ts +73 -0
  5. package/src/_internal/capabilities.ts +137 -9
  6. package/src/_internal/define-app.ts +40 -0
  7. package/src/_internal/heartbeat.ts +19 -0
  8. package/src/_internal/postgres-application-name.ts +11 -0
  9. package/src/_internal/process-sync.ts +9 -0
  10. package/src/_internal/readiness.ts +6 -1
  11. package/src/_internal/registry-validation.ts +17 -4
  12. package/src/_internal/registry.ts +32 -4
  13. package/src/_internal/resource-principals.ts +6 -0
  14. package/src/_internal/workload-credential.ts +153 -0
  15. package/src/_internal/workload-protocol.ts +36 -0
  16. package/src/ai/application-tasks.ts +5 -2
  17. package/src/ai/capabilities.ts +4 -2
  18. package/src/ai/chart-block.ts +36 -711
  19. package/src/ai/chat/map-messages.ts +270 -0
  20. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  21. package/src/ai/chat/map-notebook.tsx +301 -0
  22. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  23. package/src/ai/chat/map-widget.css +107 -0
  24. package/src/ai/chat/map-widget.tsx +1279 -0
  25. package/src/ai/chat/message-utils.ts +1 -0
  26. package/src/ai/chat/tool-names.ts +2 -0
  27. package/src/ai/chat/turn-layout.ts +2 -1
  28. package/src/ai/chat/turn-view.tsx +17 -1
  29. package/src/ai/default-tool-contracts.ts +21 -259
  30. package/src/ai/default-tools.ts +3 -0
  31. package/src/ai/executor.ts +13 -1
  32. package/src/ai/map-contracts.ts +209 -0
  33. package/src/ai/map-execution.ts +14 -0
  34. package/src/ai/map-notebook-save.ts +276 -0
  35. package/src/ai/map-notebook.ts +32 -0
  36. package/src/ai/map-opening-hours.ts +40 -0
  37. package/src/ai/map-routes.ts +84 -0
  38. package/src/ai/map-service.ts +288 -0
  39. package/src/ai/map-store.ts +137 -0
  40. package/src/ai/map-tool.ts +73 -0
  41. package/src/ai/migrate.ts +3 -0
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/routes.ts +2 -0
  44. package/src/ai/store.ts +19 -6
  45. package/src/ai/tools.ts +14 -0
  46. package/src/ai/types.ts +3 -0
  47. package/src/api/admin-app-keys.ts +66 -0
  48. package/src/api/admin-core-settings.ts +2 -0
  49. package/src/api/admin-identity.ts +27 -1
  50. package/src/api/admin-settings-env.ts +120 -0
  51. package/src/api/capabilities.ts +99 -15
  52. package/src/api/index.ts +3 -0
  53. package/src/api/mcp.ts +4 -2
  54. package/src/api/settings-ops.ts +35 -0
  55. package/src/app-key.ts +125 -0
  56. package/src/browser/CloudResourceSearch.tsx +48 -41
  57. package/src/browser/notifications.ts +25 -8
  58. package/src/capabilities/discussion-providers.ts +100 -0
  59. package/src/capabilities/executions.ts +2 -2
  60. package/src/capabilities/migrate.ts +10 -1
  61. package/src/capabilities/resource-readers.ts +69 -0
  62. package/src/capabilities/response.ts +4 -1
  63. package/src/capabilities/server-invoke.ts +64 -36
  64. package/src/capabilities/server.ts +17 -6
  65. package/src/capabilities/testing.ts +13 -0
  66. package/src/capabilities/types.ts +1 -1
  67. package/src/cli/admin/app-credentials.ts +32 -7
  68. package/src/cli/admin/app-keys.ts +226 -0
  69. package/src/cli/admin/index.ts +7 -1
  70. package/src/cli/admin/settings.ts +62 -0
  71. package/src/config/define-env.ts +63 -8
  72. package/src/config/env.ts +27 -21
  73. package/src/config/index.ts +12 -1
  74. package/src/config/workflow-ai.ts +28 -0
  75. package/src/contracts/capabilities.ts +137 -4
  76. package/src/contracts/discussions.ts +91 -0
  77. package/src/contracts/geo-document.ts +60 -0
  78. package/src/contracts/geo-snapshot.ts +86 -0
  79. package/src/contracts/geo.ts +14 -0
  80. package/src/contracts/index.ts +2 -0
  81. package/src/contracts/outgoing-mail.ts +1 -1
  82. package/src/contracts/resource-types.ts +21 -0
  83. package/src/contracts/settings-env.ts +21 -0
  84. package/src/contracts/settings-types.ts +6 -0
  85. package/src/geo/AddressAutocomplete.tsx +213 -0
  86. package/src/geo/messages.ts +29 -0
  87. package/src/geo/navigation.ts +25 -0
  88. package/src/geo/ui.ts +2 -0
  89. package/src/index.ts +9 -0
  90. package/src/runtime/clients.ts +72 -0
  91. package/src/runtime/connections.ts +37 -0
  92. package/src/runtime/resolve-connections.ts +91 -0
  93. package/src/server/client-ip.ts +1 -1
  94. package/src/services/accounts/app.ts +28 -6
  95. package/src/services/accounts/entities.ts +11 -4
  96. package/src/services/accounts/groups.ts +10 -4
  97. package/src/services/accounts/local-groups.ts +87 -38
  98. package/src/services/accounts/machine-group-management.ts +94 -0
  99. package/src/services/app-keys.ts +298 -0
  100. package/src/services/gateway.ts +6 -0
  101. package/src/services/geo/cache.ts +2 -1
  102. package/src/services/geo/geoapify.ts +3 -0
  103. package/src/services/identity/index.ts +3 -0
  104. package/src/services/identity/invocation-token.ts +110 -0
  105. package/src/services/identity/resource-readers.ts +80 -0
  106. package/src/services/identity/runtime-config.ts +14 -6
  107. package/src/services/identity/workload-runtime.ts +253 -0
  108. package/src/services/index.ts +4 -1
  109. package/src/services/outgoing-mail/resolve-host.ts +5 -4
  110. package/src/services/pdf/geo-snapshot.ts +107 -0
  111. package/src/services/pdf/index.ts +2 -0
  112. package/src/services/service-account-credentials.ts +63 -21
  113. package/src/services/settings/api.ts +3 -3
  114. package/src/services/settings/binding.ts +45 -0
  115. package/src/services/settings/core-settings.ts +99 -1
  116. package/src/services/settings/defaults.ts +16 -2
  117. package/src/services/settings/env-status.ts +69 -0
  118. package/src/services/settings/index.ts +69 -26
  119. package/src/services/settings/store.ts +73 -8
  120. package/src/services/settings/validation.ts +40 -0
  121. package/src/shared/index.ts +11 -10
  122. package/src/shared/markdown/extensions/tables.ts +10 -1
  123. package/src/ssr/AdminArea.tsx +2 -2
  124. package/src/ssr/AdminLayout.tsx +1 -1
  125. package/src/ssr/AdminSidebar.tsx +1 -1
  126. package/src/start.ts +36 -0
  127. package/src/styles/global.css +2 -0
  128. package/src/workflows/ai/index.ts +1 -0
  129. package/src/workflows/ai/runtime.ts +16 -8
  130. package/src/workflows/ai/settings.ts +5 -0
  131. package/src/shared/markdown/formula.ts +0 -1283
@@ -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(),
@@ -4,13 +4,14 @@ import type { GeoFunction } from "../../contracts/geo";
4
4
  import { requestCacheRedis } from "../request-cache-redis";
5
5
  import type { GeoRedis } from "./bucket";
6
6
 
7
- export const GEO_ADAPTER_VERSION = "geoapify-v2";
7
+ export const GEO_ADAPTER_VERSION = "geoapify-v3";
8
8
  export const GEO_MAX_JSON_BYTES = 256 * 1024;
9
9
  // Raw provider geometry is compacted before entering the public result/cache budget.
10
10
  export const GEO_MAX_ROUTE_RESPONSE_BYTES = 26 * GEO_MAX_JSON_BYTES;
11
11
  const hash = (value: string) => createHash("sha256").update(value).digest("hex");
12
12
  export const normalizeGeoInput = (input: unknown, fn: GeoFunction, path = ""): unknown => {
13
13
  const field = path.split(".").at(-1);
14
+ if (typeof input === "string" && fn === "places" && field === "name") return input.trim();
14
15
  if (typeof input === "string" && fn === "route") return field === "polyline6" ? hash(input) : input;
15
16
  if (typeof input === "string") return field === "polyline6" ? hash(input) : input.toLowerCase().trim().replace(/\s+/g, " ");
16
17
  if (typeof input === "number") {
@@ -86,6 +86,7 @@ const address = (p: RawProperties, provenance: GeoProvenance): GeoAddress => ({
86
86
  point: { lat: p.lat, lon: p.lon },
87
87
  formatted: p.formatted ?? p.address_line1 ?? "",
88
88
  confidence: p.rank?.confidence ?? null,
89
+ buildingConfidence: p.rank?.confidence_building_level ?? null,
89
90
  provenance,
90
91
  });
91
92
  const areaFilter = (area: NonNullable<GeoAutocompleteInput["area"]>) =>
@@ -247,6 +248,7 @@ export class GeoapifyAdapter {
247
248
  if (input.along && line)
248
249
  body = {
249
250
  categories,
251
+ ...(input.name !== undefined ? { name: input.name } : {}),
250
252
  limit: input.limit ?? 20,
251
253
  filter: {
252
254
  type: "polyline",
@@ -256,6 +258,7 @@ export class GeoapifyAdapter {
256
258
  };
257
259
  else {
258
260
  params.set("categories", categories.join(","));
261
+ if (input.name !== undefined) params.set("name", input.name);
259
262
  if (input.near) params.set("bias", `proximity:${input.near.lon},${input.near.lat}`);
260
263
  if (input.area) params.set("filter", areaFilter(input.area));
261
264
  }
@@ -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";
@@ -485,3 +485,113 @@ export const verifyAppAccessProbe = async (
485
485
  return null;
486
486
  }
487
487
  };
488
+
489
+ // A separate payload prevents app authority from ever entering the person/mandate resolver.
490
+ const AppQueryClaimsSchema = z
491
+ .object({
492
+ iss: z.string().url(),
493
+ aud: z.string().regex(/^app:[a-z][a-z0-9-]{0,79}$/),
494
+ token_use: z.literal("app_query"),
495
+ sub: AppIdSchema,
496
+ credential_id: z.uuid(),
497
+ service_account_id: z.uuid(),
498
+ op: OperationSchema.refine((value) => value.startsWith("capability.query:")),
499
+ schema_hash: z.string().regex(/^[a-f0-9]{64}$/),
500
+ input_hash: z.string().regex(/^[a-f0-9]{64}$/),
501
+ request_id: BoundedIdentifierSchema,
502
+ jti: z.uuid(),
503
+ iat: z.number().int(),
504
+ nbf: z.number().int(),
505
+ exp: z.number().int(),
506
+ })
507
+ .strict()
508
+ .refine((claims) => claims.nbf === claims.iat && claims.exp - claims.iat === CLOUD_INVOCATION_TOKEN_TTL_SECONDS);
509
+
510
+ export const signAppQueryInvocation = async (input: {
511
+ workload: import("./workload-auth").AuthenticatedWorkload;
512
+ targetAppId: string;
513
+ capabilityId: string;
514
+ schemaHash: string;
515
+ input: unknown;
516
+ requestId: string;
517
+ signer: PreparedIdentitySigner & { issuer: string };
518
+ }) => {
519
+ const now = Math.floor(Date.now() / 1000);
520
+ if (input.signer.signUntil.getTime() < (now + CLOUD_INVOCATION_TOKEN_TTL_SECONDS) * 1000)
521
+ throw new Error("Signing key expires before the app query");
522
+ const claims = AppQueryClaimsSchema.parse({
523
+ iss: input.signer.issuer,
524
+ aud: `app:${input.targetAppId}`,
525
+ token_use: "app_query",
526
+ sub: input.workload.appId,
527
+ credential_id: input.workload.credentialId,
528
+ service_account_id: input.workload.serviceAccountId,
529
+ op: `capability.query:${input.capabilityId}`,
530
+ schema_hash: input.schemaHash,
531
+ input_hash: appEventPayloadHash(input.input),
532
+ request_id: input.requestId,
533
+ jti: crypto.randomUUID(),
534
+ iat: now,
535
+ nbf: now,
536
+ exp: now + CLOUD_INVOCATION_TOKEN_TTL_SECONDS,
537
+ });
538
+ const token = await new SignJWT(claims)
539
+ .setProtectedHeader({ alg: CLOUD_IDENTITY_ALGORITHM, typ: CLOUD_INVOCATION_TOKEN_TYPE, kid: input.signer.kid })
540
+ .sign(input.signer.key);
541
+ if (Buffer.byteLength(token) > IDENTITY_MAX_COMPACT_TOKEN_BYTES) throw new Error("App query token exceeds its byte budget");
542
+ return { token, claims };
543
+ };
544
+
545
+ export const verifyAppQueryInvocation = async (
546
+ token: string,
547
+ expected: { appId: string; capabilityId: string; schemaHash: string; input: unknown },
548
+ options: { issuer?: string; key?: JWTVerifyGetKey; now?: Date } = {},
549
+ ) => {
550
+ if (Buffer.byteLength(token) > IDENTITY_MAX_COMPACT_TOKEN_BYTES) return null;
551
+ try {
552
+ assertProtectedHeader(decodeProtectedHeader(token));
553
+ } catch {
554
+ return null;
555
+ }
556
+ // Configuration and key-fetch failures must remain retryable, rather than look like invalid tokens.
557
+ const runtime = options.issuer && options.key ? null : await getIdentityRuntimeConfig();
558
+ try {
559
+ const issuer = options.issuer ?? runtime!.issuer;
560
+ const state = options.key ? null : remoteSet(runtime!.invocationJwksUrl);
561
+ const key = options.key ?? state!.key;
562
+ const verified = await verifyWithKeyRefresh(
563
+ (candidate) =>
564
+ jwtVerify(token, candidate, {
565
+ algorithms: [CLOUD_IDENTITY_ALGORITHM],
566
+ issuer,
567
+ audience: `app:${expected.appId}`,
568
+ typ: CLOUD_INVOCATION_TOKEN_TYPE,
569
+ clockTolerance: INVOCATION_CLOCK_TOLERANCE_SECONDS,
570
+ currentDate: options.now,
571
+ }),
572
+ key,
573
+ state ? { url: runtime!.invocationJwksUrl, state } : null,
574
+ );
575
+ const claims = AppQueryClaimsSchema.parse(verified.payload);
576
+ if (
577
+ claims.op !== `capability.query:${expected.capabilityId}` ||
578
+ claims.schema_hash !== expected.schemaHash ||
579
+ claims.input_hash !== appEventPayloadHash(expected.input)
580
+ )
581
+ return null;
582
+ return claims;
583
+ } catch (error) {
584
+ if (
585
+ error instanceof z.ZodError ||
586
+ error instanceof errors.JWTExpired ||
587
+ error instanceof errors.JWTClaimValidationFailed ||
588
+ error instanceof errors.JWTInvalid ||
589
+ error instanceof errors.JWSInvalid ||
590
+ error instanceof errors.JWSSignatureVerificationFailed ||
591
+ error instanceof errors.JWKSNoMatchingKey ||
592
+ error instanceof errors.JOSENotSupported
593
+ )
594
+ return null;
595
+ throw error;
596
+ }
597
+ };
@@ -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);