@k2b/cloud 0.47.0 → 0.49.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 (240) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/app-access-manager-sql.ts +10 -0
  3. package/src/_internal/app-access-resources.ts +2 -1
  4. package/src/_internal/app-capabilities.ts +73 -0
  5. package/src/_internal/app-event-catalog.ts +1 -1
  6. package/src/_internal/app-queries.ts +1 -1
  7. package/src/_internal/application-package.ts +2 -1
  8. package/src/_internal/bounded-json.ts +7 -1
  9. package/src/_internal/capabilities.ts +25 -2
  10. package/src/_internal/define-app.ts +33 -2
  11. package/src/_internal/heartbeat.ts +2 -0
  12. package/src/_internal/nats-connection.ts +10 -9
  13. package/src/_internal/process-sync.ts +6 -6
  14. package/src/_internal/public-surface.ts +76 -0
  15. package/src/_internal/purpose-name.ts +7 -0
  16. package/src/_internal/registry-validation.ts +1 -0
  17. package/src/_internal/registry.ts +49 -3
  18. package/src/_internal/workload-credential.ts +2 -1
  19. package/src/ai/app-skill-store.ts +2 -1
  20. package/src/ai/app-skills.ts +2 -1
  21. package/src/ai/approvals.ts +2 -1
  22. package/src/ai/chat-tasks.ts +2 -1
  23. package/src/ai/credentials.ts +2 -1
  24. package/src/ai/dictation-runtime.ts +1 -1
  25. package/src/ai/dictations.ts +1 -1
  26. package/src/ai/files-store.ts +2 -1
  27. package/src/ai/inference-calls.ts +2 -1
  28. package/src/ai/map-store.ts +1 -1
  29. package/src/ai/map-tool.ts +1 -1
  30. package/src/ai/memories.ts +1 -1
  31. package/src/ai/memory-learning-runs.ts +1 -1
  32. package/src/ai/memory-learning.ts +1 -1
  33. package/src/ai/memory-workflow-evidence.ts +1 -1
  34. package/src/ai/message-queue.ts +2 -1
  35. package/src/ai/migrate.ts +1 -1
  36. package/src/ai/model-access-migrate.ts +1 -1
  37. package/src/ai/model-access.ts +1 -1
  38. package/src/ai/model-catalog.ts +1 -1
  39. package/src/ai/model-delete.ts +1 -1
  40. package/src/ai/model-usage.ts +2 -1
  41. package/src/ai/open-questions.ts +1 -1
  42. package/src/ai/openai-api-slots.ts +2 -1
  43. package/src/ai/prefs.ts +2 -1
  44. package/src/ai/projects.ts +2 -1
  45. package/src/ai/provider-connections.ts +1 -1
  46. package/src/ai/quota-limits.ts +2 -1
  47. package/src/ai/quota-provider.ts +2 -1
  48. package/src/ai/quota-report.ts +2 -1
  49. package/src/ai/quotas-migrate.ts +2 -1
  50. package/src/ai/quotas.ts +2 -1
  51. package/src/ai/request-headers.ts +2 -1
  52. package/src/ai/review-content.ts +2 -1
  53. package/src/ai/skill-search.ts +2 -1
  54. package/src/ai/skills.ts +2 -1
  55. package/src/ai/store.ts +2 -1
  56. package/src/ai/structured-runs.ts +2 -1
  57. package/src/ai/tool-audit.ts +2 -1
  58. package/src/ai/turn-timing.ts +1 -1
  59. package/src/ai/usage.ts +2 -1
  60. package/src/ai/web-provenance.ts +2 -1
  61. package/src/api/admin-ai-quotas.ts +1 -1
  62. package/src/api/admin-ai.ts +1 -1
  63. package/src/api/admin-app-domains.ts +62 -0
  64. package/src/api/admin-app-keys.ts +47 -3
  65. package/src/api/admin-core-settings.ts +1 -1
  66. package/src/api/admin-outgoing-mail.ts +1 -1
  67. package/src/api/app-bootstrap.ts +153 -0
  68. package/src/api/app-events.ts +1 -1
  69. package/src/api/auth.ts +1 -1
  70. package/src/api/authenticate-app-request.ts +129 -0
  71. package/src/api/capabilities.ts +87 -6
  72. package/src/api/index.ts +7 -0
  73. package/src/api/me.ts +1 -1
  74. package/src/api/search.bench.ts +1 -1
  75. package/src/app-pwa/service.ts +2 -1
  76. package/src/app-pwa/transaction.ts +4 -2
  77. package/src/capabilities/claims.ts +2 -1
  78. package/src/capabilities/executions.ts +8 -4
  79. package/src/capabilities/geo-client.ts +1 -1
  80. package/src/capabilities/migrate.ts +3 -1
  81. package/src/capabilities/resource-readers.ts +2 -2
  82. package/src/capabilities/response.ts +2 -1
  83. package/src/capabilities/server-invoke.ts +73 -20
  84. package/src/capabilities/server.ts +2 -1
  85. package/src/cli/admin/app-domains.ts +140 -0
  86. package/src/cli/admin/app-keys.ts +25 -3
  87. package/src/cli/admin/index.ts +107 -88
  88. package/src/config/env.ts +10 -4
  89. package/src/contracts/app-domains.ts +91 -0
  90. package/src/contracts/app-status.ts +104 -0
  91. package/src/contracts/capabilities.ts +37 -2
  92. package/src/contracts/index.ts +1 -0
  93. package/src/contracts/outgoing-mail.ts +1 -1
  94. package/src/contracts/registry.ts +2 -0
  95. package/src/events/app-event-delivery.ts +1 -1
  96. package/src/events/live-delivery.ts +2 -1
  97. package/src/index.ts +1 -0
  98. package/src/runtime/bootstrap-client.ts +195 -0
  99. package/src/runtime/bootstrap-protocol.ts +67 -0
  100. package/src/runtime/clients.ts +6 -2
  101. package/src/runtime/connections.ts +9 -0
  102. package/src/server/app-key.ts +14 -0
  103. package/src/server/index.ts +2 -0
  104. package/src/server/ratelimit.ts +1 -1
  105. package/src/server/services/access.ts +2 -1
  106. package/src/services/account-category-policy.ts +1 -1
  107. package/src/services/account-lifecycle/audit.ts +1 -1
  108. package/src/services/account-lifecycle/index.ts +1 -1
  109. package/src/services/account-lifecycle/ipa-backfill.ts +2 -1
  110. package/src/services/accounts/app.ts +1 -1
  111. package/src/services/accounts/avatar.ts +1 -1
  112. package/src/services/accounts/base-group.ts +1 -1
  113. package/src/services/accounts/directory-shares.ts +1 -1
  114. package/src/services/accounts/duplicate-emails.ts +1 -1
  115. package/src/services/accounts/entities.ts +2 -1
  116. package/src/services/accounts/group-sql.ts +2 -1
  117. package/src/services/accounts/groups.ts +1 -1
  118. package/src/services/accounts/identities.ts +13 -11
  119. package/src/services/accounts/ipa-data.ts +1 -1
  120. package/src/services/accounts/local-groups.ts +2 -1
  121. package/src/services/accounts/machine-group-management.ts +2 -1
  122. package/src/services/accounts/posix.ts +13 -12
  123. package/src/services/accounts/request-policy.ts +2 -1
  124. package/src/services/accounts/switching.ts +1 -1
  125. package/src/services/accounts/users.ts +2 -1
  126. package/src/services/announcements/index.ts +1 -1
  127. package/src/services/app-access-management.ts +3 -9
  128. package/src/services/app-access.ts +2 -1
  129. package/src/services/app-admin-facts.ts +160 -0
  130. package/src/services/app-approval.ts +34 -30
  131. package/src/services/app-bootstrap.ts +217 -0
  132. package/src/services/app-details.ts +3 -2
  133. package/src/services/app-domain-check.ts +152 -0
  134. package/src/services/app-domains.ts +172 -0
  135. package/src/services/app-events/accept.ts +5 -5
  136. package/src/services/app-events/admin.ts +15 -15
  137. package/src/services/app-events/declarations.ts +6 -3
  138. package/src/services/app-events/deliver.ts +6 -6
  139. package/src/services/app-events.ts +3 -2
  140. package/src/services/app-keys.ts +45 -11
  141. package/src/services/approvals/content.ts +1 -1
  142. package/src/services/approvals/index.ts +2 -1
  143. package/src/services/approvals/list.ts +1 -1
  144. package/src/services/approvals/migrate.ts +1 -1
  145. package/src/services/audit/index.ts +2 -1
  146. package/src/services/auth-flows/ipa.ts +1 -1
  147. package/src/services/auth-flows/magic-link.ts +1 -1
  148. package/src/services/auth-flows/password-reset.ts +1 -1
  149. package/src/services/auth-flows/proxy-return.ts +1 -1
  150. package/src/services/downstream-budget.ts +2 -2
  151. package/src/services/geo/admin.ts +13 -12
  152. package/src/services/geo/identity.ts +46 -0
  153. package/src/services/geo/ledger.ts +118 -106
  154. package/src/services/geo/migrate.ts +2 -1
  155. package/src/services/geo/route-chain.ts +3 -3
  156. package/src/services/geo/service.ts +4 -24
  157. package/src/services/geoip/index.ts +5 -5
  158. package/src/services/geoip/store.ts +24 -21
  159. package/src/services/help/index.ts +2 -1
  160. package/src/services/help/store.ts +2 -1
  161. package/src/services/identity/app-request.ts +44 -0
  162. package/src/services/identity/constants.ts +1 -0
  163. package/src/services/identity/index.ts +1 -0
  164. package/src/services/identity/invocation-actor.ts +2 -1
  165. package/src/services/identity/invocation-token.ts +112 -0
  166. package/src/services/identity/key-ring.ts +2 -2
  167. package/src/services/identity/resource-readers.ts +1 -1
  168. package/src/services/identity/routes.ts +11 -3
  169. package/src/services/identity/runtime-config.ts +14 -3
  170. package/src/services/identity/workload-auth.ts +2 -0
  171. package/src/services/identity/workload-runtime.ts +3 -3
  172. package/src/services/index.ts +4 -0
  173. package/src/services/ipa/groups.ts +1 -1
  174. package/src/services/ipa/profile.ts +1 -1
  175. package/src/services/ipa/search.ts +1 -1
  176. package/src/services/ipa/sync.ts +1 -1
  177. package/src/services/ipa/users.ts +1 -1
  178. package/src/services/known-browsers.ts +2 -1
  179. package/src/services/legal-consent.ts +1 -1
  180. package/src/services/links/index.ts +16 -0
  181. package/src/services/links/runtime.ts +44 -0
  182. package/src/services/links/store.ts +105 -0
  183. package/src/services/logging/index.ts +13 -8
  184. package/src/services/logging/redaction.ts +6 -1
  185. package/src/services/logging/trace.ts +9 -9
  186. package/src/services/logging/web-vitals.ts +1 -1
  187. package/src/services/mandates/index.ts +2 -1
  188. package/src/services/notifications/batches.ts +1 -1
  189. package/src/services/notifications/browser.ts +1 -1
  190. package/src/services/notifications/catalog.ts +1 -1
  191. package/src/services/notifications/dispatcher.ts +1 -1
  192. package/src/services/notifications/index.ts +1 -1
  193. package/src/services/notifications/observability.ts +2 -1
  194. package/src/services/notifications/platform.ts +1 -1
  195. package/src/services/notifications/quiet.ts +1 -1
  196. package/src/services/notifications/user.ts +1 -1
  197. package/src/services/oauth-tokens.ts +1 -1
  198. package/src/services/outbox.ts +15 -14
  199. package/src/services/outgoing-mail/admin.ts +1 -1
  200. package/src/services/outgoing-mail/dispatcher.ts +1 -1
  201. package/src/services/outgoing-mail/drain.ts +2 -1
  202. package/src/services/outgoing-mail/enqueue.ts +2 -1
  203. package/src/services/outgoing-mail/messages.ts +2 -1
  204. package/src/services/outgoing-mail/retention.ts +1 -1
  205. package/src/services/outgoing-mail/send.ts +1 -1
  206. package/src/services/outgoing-mail/store.ts +2 -1
  207. package/src/services/panel-context-consent.ts +32 -28
  208. package/src/services/providers/local/auth.ts +1 -1
  209. package/src/services/providers/local/users.ts +1 -1
  210. package/src/services/pwa-devices.ts +2 -1
  211. package/src/services/rail-preferences.ts +16 -12
  212. package/src/services/rail-shortcuts.ts +12 -10
  213. package/src/services/request-cache-redis.ts +3 -3
  214. package/src/services/service-account-credentials.ts +2 -1
  215. package/src/services/service-accounts.ts +2 -1
  216. package/src/services/session/index.ts +2 -1
  217. package/src/services/session/user.ts +2 -1
  218. package/src/services/settings/core-settings.ts +20 -0
  219. package/src/services/settings/crypto.ts +2 -2
  220. package/src/services/settings/index.ts +1 -1
  221. package/src/services/settings/store.ts +6 -1
  222. package/src/services/settings/versions.ts +2 -1
  223. package/src/services/weather/forecast.ts +2 -1
  224. package/src/services/weather/locations.ts +1 -1
  225. package/src/services/weather/migrate.ts +2 -1
  226. package/src/services/weather/short-id.ts +2 -1
  227. package/src/services/webauthn.ts +1 -1
  228. package/src/ssr/admin-navigation.ts +0 -1
  229. package/src/ssr/compact-shell.browser-harness.ts +2 -0
  230. package/src/ssr/platform-messages.ts +0 -2
  231. package/src/start.ts +35 -14
  232. package/src/workflows/ai/store.ts +2 -1
  233. package/src/workflows/store/budget.ts +2 -1
  234. package/src/workflows/store/definitions.ts +2 -1
  235. package/src/workflows/store/events.ts +2 -1
  236. package/src/workflows/store/observability.ts +2 -1
  237. package/src/workflows/store/retention.ts +2 -1
  238. package/src/workflows/store/runs.ts +2 -1
  239. package/src/workflows/store/transaction.ts +2 -1
  240. package/src/workflows/store/worker.ts +2 -1
@@ -1,4 +1,3 @@
1
- import { sql } from "bun";
2
1
  import {
3
2
  DEFAULT_LINUX_IDENTITY_CONFIGURATION,
4
3
  isPosixName,
@@ -8,6 +7,7 @@ import {
8
7
  PosixOverridesSchema,
9
8
  } from "../../contracts/posix";
10
9
  import type { MutationResult } from "../../contracts/shared";
10
+ import { sql } from "../../runtime/clients";
11
11
  import { freeipa } from "../../server/services";
12
12
  import { type AuditActor, audit } from "../audit";
13
13
  import { getFreeIpaConfig } from "../freeipa-config";
@@ -128,8 +128,9 @@ const candidate = (row: IdentityRow): PosixCandidate => {
128
128
  };
129
129
 
130
130
  // Database injection is used by isolated integration tests; production uses the shared pool.
131
- export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdRanges) => {
132
- const readConfig = async (connection = db): Promise<LinuxIdentityConfiguration> => {
131
+ export const createPosixRuntime = (injectedDb?: typeof sql, readRanges = readIpaIdRanges) => {
132
+ const db = () => injectedDb ?? sql;
133
+ const readConfig = async (connection = db()): Promise<LinuxIdentityConfiguration> => {
133
134
  // Provisioning must not use a stale cache after an operator disables or changes the range.
134
135
  const [row] = await connection<{ value: string }[]>`SELECT value FROM settings.entries WHERE key = ${CONFIG_KEY}`;
135
136
  try {
@@ -256,7 +257,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
256
257
  actor?: AuditActor,
257
258
  ): Promise<MutationResult<{ id: string }>> => {
258
259
  try {
259
- return await db.begin(async (tx) => {
260
+ return await db().begin(async (tx) => {
260
261
  await lock(tx);
261
262
  const result = await write(tx);
262
263
  if (!result.ok) return result;
@@ -316,13 +317,13 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
316
317
  },
317
318
  async overview(actor: Actor, after: string | null = null, filters: { search?: string; scope?: "ready" | "all" } = {}) {
318
319
  requireAdmin(actor);
319
- const result = await rows(db, null, after, filters);
320
+ const result = await rows(db(), null, after, filters);
320
321
  const items = result.slice(0, POSIX_PAGE_SIZE).map(candidate);
321
322
  return { config: await service.configuration(actor), items, nextCursor: result.length > POSIX_PAGE_SIZE ? items.at(-1)!.id : null };
322
323
  },
323
324
  async get(actor: Actor, id: string) {
324
325
  requireAdmin(actor);
325
- const [row] = await rows(db, id, null);
326
+ const [row] = await rows(db(), id, null);
326
327
  if (!row) throw new PosixError("user_not_found", 404);
327
328
  return { config: await readConfig(), user: candidate(row) };
328
329
  },
@@ -331,7 +332,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
331
332
  const parsed = LinuxIdentityConfigurationSchema.safeParse(input);
332
333
  if (!parsed.success) throw new PosixError("invalid_configuration", 400);
333
334
  const ranges = parsed.data.enabled ? await readRanges() : [];
334
- await db.begin(async (tx) => {
335
+ await db().begin(async (tx) => {
335
336
  // Same order as provisioning; settings write upgrades this lock in the same transaction.
336
337
  await lock(tx);
337
338
  if (options?.replaceUnreadable !== true) {
@@ -360,7 +361,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
360
361
  },
361
362
  async provision(actor: Actor, id: string) {
362
363
  requireAdmin(actor);
363
- return db.begin(async (tx) => {
364
+ return db().begin(async (tx) => {
364
365
  await lock(tx);
365
366
  const config = await readConfig(tx);
366
367
  if (!config.enabled) throw new PosixError("setup_disabled");
@@ -371,7 +372,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
371
372
  requireAdmin(actor);
372
373
  const parsed = PosixOverridesSchema.safeParse(input);
373
374
  if (!parsed.success) throw new PosixError("invalid_paths", 400);
374
- return db.begin(async (tx) => {
375
+ return db().begin(async (tx) => {
375
376
  await lock(tx);
376
377
  const [row] = await rows(tx, id, null);
377
378
  if (!row) throw new PosixError("user_not_found", 404);
@@ -394,7 +395,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
394
395
  async createGroup(actor: Actor, input: { name: string; description?: string }) {
395
396
  requireAdmin(actor);
396
397
  const ranges = await readRanges();
397
- return db.begin(async (tx) => {
398
+ return db().begin(async (tx) => {
398
399
  await lock(tx);
399
400
  const created = await createLocalGroup(input, tx);
400
401
  if (!created.ok) throw new PosixError("group_conflict");
@@ -405,7 +406,7 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
405
406
  async provisionGroup(actor: Actor, id: string) {
406
407
  requireAdmin(actor);
407
408
  const ranges = await readRanges();
408
- return db.begin(async (tx) => {
409
+ return db().begin(async (tx) => {
409
410
  await lock(tx);
410
411
  return assignGroup(tx, actor, id, ranges);
411
412
  });
@@ -413,5 +414,5 @@ export const createPosixRuntime = (db: typeof sql = sql, readRanges = readIpaIdR
413
414
  };
414
415
  return { service, writeLocalAccount };
415
416
  };
416
- export const createPosixService = (db: typeof sql = sql, readRanges = readIpaIdRanges) => createPosixRuntime(db, readRanges).service;
417
+ export const createPosixService = (db?: typeof sql, readRanges = readIpaIdRanges) => createPosixRuntime(db, readRanges).service;
417
418
  export const { service: posix, writeLocalAccount } = createPosixRuntime();
@@ -1,4 +1,5 @@
1
- import { sql } from "bun";
1
+ import { sql } from "../../runtime/clients";
2
+
2
3
  import { decryptValue } from "../settings/crypto";
3
4
 
4
5
  /** Read durable policy so disabling new requests is not delayed by the settings cache. */
@@ -1,5 +1,5 @@
1
- import { sql } from "bun";
2
1
  import type { UserProfile, UserProvider } from "../../contracts/shared";
2
+ import { sql } from "../../runtime/clients";
3
3
  import * as settings from "../settings";
4
4
  import type { IpaAccountTransitionPolicy } from "./model";
5
5
 
@@ -1,4 +1,4 @@
1
- import { type SQL, sql } from "bun";
1
+ import type { SQL } from "bun";
2
2
  import type {
3
3
  BaseUser,
4
4
  MutationResult,
@@ -9,6 +9,7 @@ import type {
9
9
  UserProfile,
10
10
  UserProvider,
11
11
  } from "../../contracts/shared";
12
+ import { sql } from "../../runtime/clients";
12
13
  import { freeipa } from "../../server/services";
13
14
  import { createAuthLoginUrl } from "../../shared/redirect";
14
15
  import { isAccountCategoryAllowed } from "../account-category-policy";
@@ -1,5 +1,4 @@
1
1
  import { err, fail, ok, type Result } from "@k2b/stdlib";
2
- import { sql } from "bun";
3
2
  import { HTTPException } from "hono/http-exception";
4
3
  import { z } from "zod";
5
4
  import type {
@@ -11,6 +10,7 @@ import type {
11
10
  } from "../../contracts/announcements";
12
11
  import { AnnouncementDisplayEntrySchema } from "../../contracts/announcements";
13
12
  import { hasRole, type User } from "../../contracts/shared";
13
+ import { sql } from "../../runtime/clients";
14
14
  import { markdown } from "../../shared/markdown";
15
15
  import { claimCacheFill, completeCacheFill } from "../cache-fill";
16
16
  import { logger } from "../logging";
@@ -1,7 +1,9 @@
1
- import { type SQL, sql } from "bun";
1
+ import type { SQL } from "bun";
2
+ import { managerEligibility } from "../_internal/app-access-manager-sql";
2
3
  import type { AppMeta } from "../contracts/app";
3
4
  import type { AppAccessDeclaration, AppAccessSnapshot } from "../contracts/app-access";
4
5
  import { publishAppAccessChanged, wakeAppAccessChanged } from "../events/app-access";
6
+ import { sql } from "../runtime/clients";
5
7
  import { isAppSessionActor, userFromActor } from "../server/actor";
6
8
  import type { RequestAuthority } from "../server/middleware/auth";
7
9
  import {
@@ -107,14 +109,6 @@ const listEntries = async (appId: string, db: SQL = sql): Promise<AccessEntry[]>
107
109
  : { type: "public" },
108
110
  }));
109
111
  };
110
- /** Shared eligibility predicate for the last-manager guard and its presentation. */
111
- const managerEligibility = (db: SQL) => db`
112
- EXISTS (SELECT 1 FROM auth.users u WHERE (u.account_expires IS NULL OR u.account_expires > now()) AND
113
- (u.id = a.user_id OR (a.group_id IS NOT NULL AND a.group_id IN (${recursiveGroupIdsSubquery(db`u.id`)}))))
114
- OR EXISTS (SELECT 1 FROM auth.service_accounts sa WHERE sa.id = a.service_account_id
115
- AND sa.status = 'active' AND sa.kind IN ('standalone', 'agent'))
116
- `;
117
-
118
112
  const entryPresentation = async (appId: string): Promise<Map<string, { groupMemberCount?: number; effectiveManager: boolean }>> => {
119
113
  const rows = await sql<{ id: string; group_member_count: number | null; effective_manager: boolean }[]>`
120
114
  SELECT a.id,
@@ -1,8 +1,9 @@
1
- import { type SQL, sql } from "bun";
1
+ import type { SQL } from "bun";
2
2
  import type { Context } from "hono";
3
3
  import { RESOURCE_ACCESS_TIMEOUT_MS, resourceAppAccess } from "../_internal/app-access-resources";
4
4
  import type { AppMeta } from "../contracts/app";
5
5
  import { AppAccessRuleSchema, type FixedAppAccessRule } from "../contracts/app-access";
6
+ import { sql } from "../runtime/clients";
6
7
  import { userFromActor } from "../server/actor";
7
8
  import { type AuthContext, auth, type RequestAuthority } from "../server/middleware/auth";
8
9
  import { buildAccessPrincipalCondition } from "../server/services/access";
@@ -0,0 +1,160 @@
1
+ import { managerEligibility } from "../_internal/app-access-manager-sql";
2
+ import { sql } from "../runtime/clients";
3
+ import type { RequestAuthority } from "../server/middleware/auth";
4
+ import { AppAccessManagementError, canRepairAppAccess } from "./app-access-management";
5
+
6
+ export type AppAdminFacts = {
7
+ access: { people: number; groups: number; serviceAccounts: number; managers: number; configured: boolean } | null;
8
+ keys: {
9
+ active: number;
10
+ expired: number;
11
+ revoked: number;
12
+ lastUsedAt: string | null;
13
+ firstCreatedAt: string | null;
14
+ accountStatus: "active" | "disabled" | null;
15
+ };
16
+ tokens: { active: number; total: number };
17
+ events: { hears: number; sends: number | null; failedDeliveries: number; waitingDeliveries: number };
18
+ notifications: number | null;
19
+ firstKnownAt: string | null;
20
+ };
21
+
22
+ /** Core-owned administration summary: at most 500 input IDs and four batched SQL reads. */
23
+ export const appAdminFacts = async (authority: RequestAuthority, appIds: readonly string[]): Promise<Map<string, AppAdminFacts>> => {
24
+ if (!canRepairAppAccess(authority)) throw new AppAccessManagementError("FORBIDDEN", "Cloud administration authority is required.");
25
+ if (appIds.length > 500) throw new AppAccessManagementError("TOO_MANY_APPS", "At most 500 application IDs may be read at once.", 400);
26
+ const ids = [...new Set(appIds)];
27
+ const facts = new Map<string, AppAdminFacts>(
28
+ ids.map((id) => [
29
+ id,
30
+ {
31
+ access: null,
32
+ keys: { active: 0, expired: 0, revoked: 0, lastUsedAt: null, firstCreatedAt: null, accountStatus: null },
33
+ tokens: { active: 0, total: 0 },
34
+ events: { hears: 0, sends: 0, failedDeliveries: 0, waitingDeliveries: 0 },
35
+ notifications: 0,
36
+ firstKnownAt: null,
37
+ },
38
+ ]),
39
+ );
40
+ if (!ids.length) return facts;
41
+
42
+ const [access, credentials, events, notifications] = await Promise.all([
43
+ sql<
44
+ {
45
+ app_id: string;
46
+ people: number;
47
+ groups: number;
48
+ service_accounts: number;
49
+ managers: number;
50
+ configured: boolean;
51
+ first_known_at: Date | null;
52
+ }[]
53
+ >`
54
+ SELECT COALESCE(aa.app_id, s.app_id) AS app_id,
55
+ count(a.user_id)::int AS people, count(a.group_id)::int AS groups,
56
+ count(a.service_account_id)::int AS service_accounts,
57
+ count(*) FILTER (WHERE a.permission = 'admin' AND (${managerEligibility(sql)}))::int AS managers,
58
+ s.configured_at IS NOT NULL AS configured,
59
+ LEAST(min(a.created_at), s.configured_at) AS first_known_at
60
+ FROM auth.app_access aa
61
+ FULL JOIN auth.app_access_state s ON s.app_id = aa.app_id
62
+ LEFT JOIN auth.access a ON a.id = aa.access_id
63
+ WHERE COALESCE(aa.app_id, s.app_id) IN ${sql(ids)}
64
+ GROUP BY aa.app_id, s.app_id, s.configured_at
65
+ `,
66
+ sql<
67
+ {
68
+ app_id: string;
69
+ active: number;
70
+ expired: number;
71
+ revoked: number;
72
+ last_used_at: Date | null;
73
+ first_created_at: Date | null;
74
+ account_status: "active" | "disabled";
75
+ active_tokens: number;
76
+ total_tokens: number;
77
+ first_known_at: Date;
78
+ }[]
79
+ >`
80
+ WITH accounts AS (
81
+ SELECT id, app_id, status, created_at FROM auth.service_accounts
82
+ WHERE kind = 'resource_bound' AND resource_type = 'cloud.app' AND resource_id = app_id
83
+ AND app_id IN ${sql(ids)}
84
+ ), canonical_accounts AS (
85
+ SELECT DISTINCT ON (app_id) app_id, status FROM accounts ORDER BY app_id, created_at, id
86
+ )
87
+ SELECT sa.app_id,
88
+ count(*) FILTER (WHERE c.kind = 'app_key' AND c.status = 'active'
89
+ AND (c.expires_at IS NULL OR c.expires_at > now()))::int AS active,
90
+ count(*) FILTER (WHERE c.kind = 'app_key' AND c.status <> 'revoked' AND c.expires_at <= now())::int AS expired,
91
+ count(*) FILTER (WHERE c.kind = 'app_key' AND c.status = 'revoked')::int AS revoked,
92
+ max(c.last_used_at) FILTER (WHERE c.kind = 'app_key') AS last_used_at,
93
+ min(c.created_at) FILTER (WHERE c.kind = 'app_key') AS first_created_at,
94
+ canonical.status AS account_status,
95
+ count(*) FILTER (WHERE c.kind <> 'app_key' AND c.status = 'active'
96
+ AND (c.expires_at IS NULL OR c.expires_at > now()))::int AS active_tokens,
97
+ count(*) FILTER (WHERE c.kind <> 'app_key')::int AS total_tokens,
98
+ LEAST(min(sa.created_at), min(c.created_at)) AS first_known_at
99
+ FROM accounts sa
100
+ JOIN canonical_accounts canonical ON canonical.app_id = sa.app_id
101
+ LEFT JOIN auth.service_account_credentials c ON c.service_account_id = sa.id
102
+ GROUP BY sa.app_id, canonical.status
103
+ `,
104
+ sql<{ app_id: string; hears: number; sends: number; failed: number; waiting: number }[]>`
105
+ WITH subscriptions AS (
106
+ SELECT app_id, count(*) FILTER (WHERE state = 'active')::int AS hears
107
+ FROM events.app_subscriptions WHERE app_id IN ${sql(ids)} GROUP BY app_id
108
+ ), declarations AS (
109
+ SELECT app_id, count(*)::int AS sends
110
+ FROM events.app_declarations WHERE app_id IN ${sql(ids)} AND retired_at IS NULL GROUP BY app_id
111
+ ), deliveries AS (
112
+ SELECT subscription.app_id, count(*) FILTER (WHERE delivery.state = 'failed')::int AS failed,
113
+ count(*) FILTER (WHERE delivery.state IN ('pending', 'retrying'))::int AS waiting
114
+ FROM events.app_subscriptions subscription
115
+ JOIN events.app_deliveries delivery ON delivery.subscription_id = subscription.id
116
+ WHERE subscription.app_id IN ${sql(ids)} AND delivery.state IN ('failed', 'pending', 'retrying')
117
+ GROUP BY subscription.app_id
118
+ )
119
+ SELECT COALESCE(s.app_id, d.app_id, f.app_id) AS app_id, COALESCE(s.hears, 0) AS hears,
120
+ COALESCE(d.sends, 0) AS sends, COALESCE(f.failed, 0) AS failed, COALESCE(f.waiting, 0) AS waiting
121
+ FROM subscriptions s FULL JOIN declarations d ON d.app_id = s.app_id
122
+ FULL JOIN deliveries f ON f.app_id = COALESCE(s.app_id, d.app_id)
123
+ `,
124
+ sql<{ app_id: string; count: number }[]>`
125
+ SELECT app_id, count(*)::int AS count FROM notifications.definitions
126
+ WHERE app_id IN ${sql(ids)} AND active = true GROUP BY app_id
127
+ `,
128
+ ]);
129
+
130
+ for (const row of access) {
131
+ const fact = facts.get(row.app_id)!;
132
+ fact.access = {
133
+ people: row.people,
134
+ groups: row.groups,
135
+ serviceAccounts: row.service_accounts,
136
+ managers: row.managers,
137
+ configured: row.configured,
138
+ };
139
+ fact.firstKnownAt = row.first_known_at?.toISOString() ?? null;
140
+ }
141
+ for (const row of credentials) {
142
+ const fact = facts.get(row.app_id)!;
143
+ fact.keys = {
144
+ active: row.active,
145
+ expired: row.expired,
146
+ revoked: row.revoked,
147
+ lastUsedAt: row.last_used_at?.toISOString() ?? null,
148
+ firstCreatedAt: row.first_created_at?.toISOString() ?? null,
149
+ accountStatus: row.account_status,
150
+ };
151
+ fact.tokens = { active: row.active_tokens, total: row.total_tokens };
152
+ const firstKnownAt = row.first_known_at.toISOString();
153
+ if (fact.firstKnownAt === null || firstKnownAt < fact.firstKnownAt) fact.firstKnownAt = firstKnownAt;
154
+ }
155
+ for (const row of events) {
156
+ facts.get(row.app_id)!.events = { hears: row.hears, sends: row.sends, failedDeliveries: row.failed, waitingDeliveries: row.waiting };
157
+ }
158
+ for (const row of notifications) facts.get(row.app_id)!.notifications = row.count;
159
+ return facts;
160
+ };
@@ -1,4 +1,4 @@
1
- import { type SQL, sql } from "bun";
1
+ import type { SQL } from "bun";
2
2
  import type { z } from "zod";
3
3
  import { lazySync } from "../_internal/process-sync";
4
4
  import { env } from "../config/env";
@@ -25,6 +25,7 @@ import {
25
25
  appPairingProofMessage,
26
26
  APP_APPROVAL_LIMITS as limits,
27
27
  } from "../contracts/app-approval";
28
+ import { sql } from "../runtime/clients";
28
29
  import { addressPrefix } from "../server/request-context/address";
29
30
  import { publicCloudOrigin } from "../shared/app-url";
30
31
  import { isAccountCategoryAllowed } from "./account-category-policy";
@@ -296,12 +297,13 @@ const verify = async (key: AppDevicePublicKey, message: string, signature: strin
296
297
  /** One owner for production API and isolated HTTP consumers. Config is re-read
297
298
  * per operation; SQL transactions serialize device decisions and revocation. */
298
299
  export const createAppApprovalService = (
299
- db: SQL = sql,
300
- configuration = (requireEnabled = true) => readAppApprovalConfig(db, requireEnabled),
300
+ injectedDb?: SQL,
301
+ configuration = (requireEnabled = true) => readAppApprovalConfig(injectedDb ?? sql, requireEnabled),
301
302
  hints: AppLoginDecisionHints = syncLoginDecisionHints,
302
303
  push: AppLoginPushSender = fetchPushSender,
303
304
  addresses: AppLoginAddressStore = valkeyAppLoginAddressStore,
304
305
  ) => {
306
+ const db = () => injectedDb ?? sql;
305
307
  // Error messages may contain their inputs; log only the error class, never addresses or proofs.
306
308
  const addressFailure = (error: unknown) =>
307
309
  log.warn("App login address hint unavailable", { error: error instanceof Error ? error.name : "UnknownError" });
@@ -332,12 +334,12 @@ export const createAppApprovalService = (
332
334
  const cleanup = async () => {
333
335
  // Bounded opportunistic maintenance: each accepted write retires up to 100
334
336
  // expired rows per table, so bursts cannot trigger an unbounded sweep.
335
- await db`DELETE FROM auth.app_device_changes WHERE id IN (SELECT id FROM auth.app_device_changes WHERE expires_at < now() - interval '1 minute' ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
336
- await db`DELETE FROM auth.app_pairings WHERE id IN (SELECT p.id FROM auth.app_pairings p
337
+ await db()`DELETE FROM auth.app_device_changes WHERE id IN (SELECT id FROM auth.app_device_changes WHERE expires_at < now() - interval '1 minute' ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
338
+ await db()`DELETE FROM auth.app_pairings WHERE id IN (SELECT p.id FROM auth.app_pairings p
337
339
  WHERE p.expires_at < now() - interval '1 minute' AND NOT EXISTS (SELECT 1 FROM auth.app_device_changes c WHERE c.pairing_id=p.id)
338
340
  ORDER BY p.expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
339
- await db`DELETE FROM auth.app_logins WHERE id IN (SELECT id FROM auth.app_logins WHERE expires_at < now() AND NOT (kind='terminal' AND state='approved') ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
340
- await db`DELETE FROM auth.app_device_proofs WHERE (device_id,jti) IN (SELECT device_id,jti FROM auth.app_device_proofs WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
341
+ await db()`DELETE FROM auth.app_logins WHERE id IN (SELECT id FROM auth.app_logins WHERE expires_at < now() AND NOT (kind='terminal' AND state='approved') ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
342
+ await db()`DELETE FROM auth.app_device_proofs WHERE (device_id,jti) IN (SELECT device_id,jti FROM auth.app_device_proofs WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
341
343
  };
342
344
  const pairingOwner = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
343
345
  if (
@@ -409,7 +411,7 @@ export const createAppApprovalService = (
409
411
  if (!cfg.enabled || !cfg.appOrigin) return;
410
412
  try {
411
413
  // A withdrawn device change keeps its window, so withdrawing and asking again cannot push without bound.
412
- const rows = await db<{ push_token: string }[]>`
414
+ const rows = await db()<{ push_token: string }[]>`
413
415
  UPDATE auth.app_devices d SET login_wake_at=now(), login_wake_ref=${requestId}::uuid
414
416
  WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL
415
417
  AND (${options?.accept ?? null}::text IS NULL OR ${options?.accept ?? null}::text=ANY(d.accepts))
@@ -423,7 +425,7 @@ export const createAppApprovalService = (
423
425
  Array.from(new Set(rows.map((row) => row.push_token)), async (token) => {
424
426
  const status = await push(cfg.appOrigin, { token, cloudOrigin: cfg.issuer, requestRef: requestId });
425
427
  if (status === 410)
426
- await db`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
428
+ await db()`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
427
429
  }),
428
430
  );
429
431
  } catch (error) {
@@ -463,7 +465,7 @@ export const createAppApprovalService = (
463
465
  try {
464
466
  const cfg = await config(false);
465
467
  if (!cfg.enabled || !cfg.appOrigin) return;
466
- const rows = await db<{ push_token: string; user_id: string }[]>`
468
+ const rows = await db()<{ push_token: string; user_id: string }[]>`
467
469
  UPDATE auth.app_devices d SET action_wake_at=now(), action_wake_ref=${approvalId}::uuid
468
470
  WHERE d.id IN (
469
471
  SELECT device.id FROM auth.app_devices device JOIN approvals.requests r ON r.approver_user_id=device.user_id
@@ -483,7 +485,7 @@ export const createAppApprovalService = (
483
485
  Array.from(tokens, async ([token, userId]) => {
484
486
  const status = await push(cfg.appOrigin, { token, cloudOrigin: cfg.issuer, requestRef: approvalId, kind: "action" });
485
487
  if (status === 410)
486
- await db`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
488
+ await db()`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
487
489
  }),
488
490
  );
489
491
  } catch (error) {
@@ -492,7 +494,7 @@ export const createAppApprovalService = (
492
494
  },
493
495
  maintain: async (notify: (notice: AppDeviceEnrollmentNotice) => Promise<unknown>, signal?: AbortSignal) => {
494
496
  await cleanup();
495
- const rows = await db<(DeviceRow & { has_email: boolean })[]>`
497
+ const rows = await db()<(DeviceRow & { has_email: boolean })[]>`
496
498
  SELECT d.*, COALESCE(btrim(u.mail), '') <> '' AS has_email
497
499
  FROM auth.app_devices d JOIN auth.users u ON u.id = d.user_id
498
500
  WHERE d.notified_at IS NULL ORDER BY d.created_at LIMIT ${limits.pageSize}`;
@@ -503,7 +505,7 @@ export const createAppApprovalService = (
503
505
  // The notice is email-only, so an account without email relies on the
504
506
  // enrollment audit entry instead of a delivery that can only fail.
505
507
  if (row.has_email) await notify({ deviceId: row.id, userId: row.user_id, name: row.name, assisted: row.assisted });
506
- await db`UPDATE auth.app_devices SET notified_at=now() WHERE id=${row.id}::uuid AND notified_at IS NULL`;
508
+ await db()`UPDATE auth.app_devices SET notified_at=now() WHERE id=${row.id}::uuid AND notified_at IS NULL`;
507
509
  }
508
510
  },
509
511
  info: async () => {
@@ -524,7 +526,7 @@ export const createAppApprovalService = (
524
526
  const id = crypto.randomUUID(),
525
527
  token = secret(),
526
528
  expiresAt = future(limits.pairingSeconds);
527
- return db.begin(async (tx) => {
529
+ return db().begin(async (tx) => {
528
530
  await fresh(tx, actor);
529
531
  if (targetId !== actor.userId && (!actor.admin || !cfg.adminPairing)) return reject("FORBIDDEN", 403);
530
532
  await tx`SELECT id FROM auth.users WHERE id = ${targetId}::uuid FOR UPDATE`;
@@ -549,7 +551,7 @@ export const createAppApprovalService = (
549
551
  claimPairing: async (claim: { pairingId: string; secret: string; publicKey: AppDevicePublicKey; name: string; signature: string }) => {
550
552
  const cfg = await config();
551
553
  if (!(await verify(claim.publicKey, appPairingProofMessage(cfg.issuer, claim), claim.signature))) return reject("FORBIDDEN", 403);
552
- return db.begin(async (tx) => {
554
+ return db().begin(async (tx) => {
553
555
  const [p] = await tx<
554
556
  PairingRow[]
555
557
  >`SELECT * FROM auth.app_pairings WHERE id = ${claim.pairingId}::uuid AND issuer = ${cfg.issuer} FOR UPDATE`;
@@ -564,7 +566,7 @@ export const createAppApprovalService = (
564
566
  },
565
567
  pairingResult: async (id: string, token: string, key: AppDevicePublicKey) => {
566
568
  const cfg = await config();
567
- const [p] = await db<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
569
+ const [p] = await db()<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
568
570
  if (!p || !matches(token, p.secret_hash)) return reject("FORBIDDEN", 403);
569
571
  const age = Date.now() - new Date(p.expires_at).getTime();
570
572
  if (age >= 0 && !(age < 60_000 && (p.state === "confirmed" || p.state === "cancelled"))) return reject("FORBIDDEN", 403);
@@ -576,7 +578,7 @@ export const createAppApprovalService = (
576
578
  },
577
579
  inspectPairing: async (actor: AppApprovalActor, id: string) => {
578
580
  const cfg = await config();
579
- return db.begin(async (tx) => {
581
+ return db().begin(async (tx) => {
580
582
  const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
581
583
  if (!p) return reject("UNAVAILABLE", 404);
582
584
  const [change] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes WHERE pairing_id=${p.id}::uuid`;
@@ -598,7 +600,7 @@ export const createAppApprovalService = (
598
600
  confirmPairing: async (actor: AppApprovalActor, id: string, code: string): Promise<z.infer<typeof AppPairingConfirmResultSchema>> => {
599
601
  const cfg = await config();
600
602
  let wakeId: string | undefined;
601
- const result = await db.begin(async (tx) => {
603
+ const result = await db().begin(async (tx) => {
602
604
  const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
603
605
  if (!snapshot) return reject("CONFLICT", 409);
604
606
  await tx`SELECT id FROM auth.users WHERE id=${snapshot.user_id}::uuid FOR NO KEY UPDATE`;
@@ -636,7 +638,7 @@ export const createAppApprovalService = (
636
638
  },
637
639
  cancelPairing: async (actor: AppApprovalActor, id: string) => {
638
640
  const cfg = await config();
639
- return db.begin(async (tx) => {
641
+ return db().begin(async (tx) => {
640
642
  const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
641
643
  if (!snapshot) return reject("UNAVAILABLE", 404);
642
644
  await tx`SELECT id FROM auth.users WHERE id=${snapshot.user_id}::uuid FOR NO KEY UPDATE`;
@@ -656,7 +658,7 @@ export const createAppApprovalService = (
656
658
  const cfg = await config(false);
657
659
  const changeId = "changeId" in lookup ? lookup.changeId : null,
658
660
  deviceId = "deviceId" in lookup ? lookup.deviceId : null;
659
- const [row] = await db<ChangeRow[]>`SELECT * FROM auth.app_device_changes
661
+ const [row] = await db()<ChangeRow[]>`SELECT * FROM auth.app_device_changes
660
662
  WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND requested_by_sid=${actor.sid}::uuid AND kind='revoke'
661
663
  AND (${changeId}::uuid IS NULL OR id=${changeId}::uuid) AND (${deviceId}::uuid IS NULL OR device_id=${deviceId}::uuid)
662
664
  ORDER BY created_at DESC, id DESC LIMIT 1`;
@@ -665,7 +667,7 @@ export const createAppApprovalService = (
665
667
  },
666
668
  cancelChange: async (actor: AppApprovalActor, id: string) => {
667
669
  const cfg = await config(false);
668
- return db.begin(async (tx) => {
670
+ return db().begin(async (tx) => {
669
671
  await tx`SELECT id FROM auth.users WHERE id=${actor.userId}::uuid FOR NO KEY UPDATE`;
670
672
  const change = await ownedChange(tx, cfg, actor, id);
671
673
  if (changeStatus(change).state !== "pending") return reject("CONFLICT", 409);
@@ -676,7 +678,7 @@ export const createAppApprovalService = (
676
678
  },
677
679
  listDevices: async (actor: AppApprovalActor, after?: string) => {
678
680
  const cfg = await config(false);
679
- const rows = await db<
681
+ const rows = await db()<
680
682
  DeviceRow[]
681
683
  >`SELECT * FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND (${after ?? null}::uuid IS NULL OR id > ${after ?? null}::uuid) ORDER BY id LIMIT ${limits.pageSize + 1}`;
682
684
  return {
@@ -692,7 +694,7 @@ export const createAppApprovalService = (
692
694
  ): Promise<z.infer<typeof AppDeviceRevokeResultSchema> | undefined> => {
693
695
  const cfg = await config(false);
694
696
  let wakeId: string | undefined;
695
- const result = await db.begin(async (tx) => {
697
+ const result = await db().begin(async (tx) => {
696
698
  await tx`SELECT id FROM auth.users WHERE id=${actor.userId}::uuid FOR NO KEY UPDATE`;
697
699
  const user = await fresh(tx, actor);
698
700
  const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices
@@ -723,7 +725,7 @@ export const createAppApprovalService = (
723
725
  listUserDevices: async (actor: AppDeviceAdministrator, userId: string): Promise<AppDeviceView[]> => {
724
726
  if (!actor.admin) return reject("FORBIDDEN", 403);
725
727
  const cfg = await config(false);
726
- const rows = await db<
728
+ const rows = await db()<
727
729
  DeviceRow[]
728
730
  >`SELECT * FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL ORDER BY created_at, id LIMIT ${limits.devicesPerAccount}`;
729
731
  return rows.map(view);
@@ -733,7 +735,7 @@ export const createAppApprovalService = (
733
735
  revokeUserDevice: async (actor: AppDeviceAdministrator, userId: string, id: string) => {
734
736
  if (!actor.admin) return reject("FORBIDDEN", 403);
735
737
  const cfg = await config(false);
736
- return db.begin(async (tx) => {
738
+ return db().begin(async (tx) => {
737
739
  const [device] = await tx<
738
740
  DeviceRow[]
739
741
  >`SELECT * FROM auth.app_devices WHERE id=${id}::uuid AND issuer=${cfg.issuer} AND user_id=${userId}::uuid FOR UPDATE`;
@@ -753,7 +755,7 @@ export const createAppApprovalService = (
753
755
  code = comparison(),
754
756
  expiresAt = future(limits.loginSeconds);
755
757
  let owner: string | undefined;
756
- await db.begin(async (tx) => {
758
+ await db().begin(async (tx) => {
757
759
  let candidate = await loginCandidate(tx, identifier, category);
758
760
  if (candidate && !(await loginBudget(tx, cfg.issuer, candidate.id))) candidate = undefined;
759
761
  const now = new Date();
@@ -789,7 +791,7 @@ export const createAppApprovalService = (
789
791
  code = comparison(),
790
792
  expiresAt = future(limits.loginSeconds);
791
793
  let owner: string | undefined;
792
- await db.begin(async (tx) => {
794
+ await db().begin(async (tx) => {
793
795
  let candidate = await loginCandidate(tx, input.identifier);
794
796
  const subject = candidate ?? { id: crypto.randomUUID(), profile: "user" as const };
795
797
  // Even a decoy performs the client-access, device and shared-budget queries.
@@ -830,7 +832,7 @@ export const createAppApprovalService = (
830
832
  return reject("FORBIDDEN", 403);
831
833
  await cleanup();
832
834
  let decidedBrowserId: string | undefined;
833
- const outcome = await db.begin(async (tx) => {
835
+ const outcome = await db().begin(async (tx) => {
834
836
  let changeUserId: string | undefined;
835
837
  if (proof.command.operation === "change") {
836
838
  const [snapshot] = await tx<
@@ -1105,7 +1107,9 @@ export const createAppApprovalService = (
1105
1107
  browserStatus: async (id: string, token: string, hold?: { ms: number; signal?: AbortSignal }) => {
1106
1108
  const cfg = await config();
1107
1109
  const read = async () => {
1108
- const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer}`;
1110
+ const [row] = await db()<
1111
+ LoginRow[]
1112
+ >`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer}`;
1109
1113
  if (!row || !matches(token, row.browser_hash)) return reject("UNAVAILABLE", 404);
1110
1114
  const expiresIn = new Date(row.expires_at).getTime() - Date.now();
1111
1115
  return { state: expiresIn <= 0 ? "expired" : row.state, expiresIn };
@@ -1134,7 +1138,7 @@ export const createAppApprovalService = (
1134
1138
  },
1135
1139
  consumeLogin: async (id: string, token: string) => {
1136
1140
  const cfg = await config();
1137
- return db.begin(async (tx) => {
1141
+ return db().begin(async (tx) => {
1138
1142
  // Lock order is device -> request for both approval and completion.
1139
1143
  const [snapshot] = await tx<
1140
1144
  LoginRow[]