@k2b/cloud 0.42.0 → 0.44.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 (352) hide show
  1. package/package.json +13 -5
  2. package/scripts/app-favicon.ts +7 -6
  3. package/scripts/build.ts +3 -0
  4. package/scripts/installable-assets.ts +61 -0
  5. package/scripts/preload.ts +3 -0
  6. package/src/_internal/app-access-path.ts +12 -0
  7. package/src/_internal/app-access-request.ts +13 -0
  8. package/src/_internal/app-access-resources.ts +141 -0
  9. package/src/_internal/app-event-catalog.ts +40 -0
  10. package/src/_internal/app-event-schemas.ts +127 -0
  11. package/src/_internal/application-package.ts +40 -0
  12. package/src/_internal/built-in-app-ids.json +27 -0
  13. package/src/_internal/capabilities.ts +185 -12
  14. package/src/_internal/capability-origin.ts +4 -0
  15. package/src/_internal/capability-streams.ts +10 -5
  16. package/src/_internal/define-app.ts +159 -45
  17. package/src/_internal/installable-assets.ts +90 -0
  18. package/src/_internal/installable-head.ts +22 -0
  19. package/src/_internal/object-store-area.ts +9 -0
  20. package/src/_internal/object-store-config.ts +101 -0
  21. package/src/_internal/object-store-key.ts +14 -0
  22. package/src/_internal/page-responses.ts +34 -3
  23. package/src/_internal/process-identity.ts +7 -2
  24. package/src/_internal/registry-validation.ts +15 -1
  25. package/src/_internal/registry.ts +44 -1
  26. package/src/_internal/runtime-context.ts +9 -1
  27. package/src/_internal/runtime-watcher.ts +8 -0
  28. package/src/_internal/status-preserving-ssr.ts +12 -1
  29. package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
  30. package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
  31. package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
  32. package/src/access/AppAccessEditor.tsx +380 -0
  33. package/src/access/PermissionEditor.browser-harness.tsx +22 -2
  34. package/src/access/PermissionEditor.tsx +386 -77
  35. package/src/access/PrincipalAvatar.tsx +63 -0
  36. package/src/access/PrincipalPicker.tsx +29 -8
  37. package/src/access/ResourceApiKeys.tsx +3 -4
  38. package/src/access/app-access-adapter.ts +51 -0
  39. package/src/access/app-access-messages.ts +172 -0
  40. package/src/access/messages.ts +14 -0
  41. package/src/access/ui.ts +4 -1
  42. package/src/account/Avatar.tsx +21 -3
  43. package/src/account/ProfilePictureDialog.tsx +699 -0
  44. package/src/account/profile-picture-messages.ts +269 -0
  45. package/src/account/ui.ts +8 -1
  46. package/src/ai/admin.ts +3 -0
  47. package/src/ai/app-frame.ts +12 -0
  48. package/src/ai/application-tasks.ts +101 -0
  49. package/src/ai/assistant-models.ts +126 -64
  50. package/src/ai/capabilities.ts +19 -4
  51. package/src/ai/capability-execution.ts +5 -2
  52. package/src/ai/chart-block.ts +63 -30
  53. package/src/ai/chat/chart-block.tsx +12 -3
  54. package/src/ai/chat/composer-adapter.ts +8 -7
  55. package/src/ai/chat/message-actions.tsx +1 -1
  56. package/src/ai/chat/message-utils.ts +2 -2
  57. package/src/ai/chat/presentation.tsx +22 -1
  58. package/src/ai/chat-quotas.ts +5 -4
  59. package/src/ai/chat-tasks.ts +2 -2
  60. package/src/ai/client/controller.ts +11 -10
  61. package/src/ai/client/projection.ts +3 -3
  62. package/src/ai/code-capability-routes.ts +1 -0
  63. package/src/ai/compaction.ts +3 -3
  64. package/src/ai/credentials.ts +13 -114
  65. package/src/ai/default-tool-contracts.ts +4 -3
  66. package/src/ai/dictation-runtime.ts +4 -4
  67. package/src/ai/enrich.ts +2 -2
  68. package/src/ai/executor.ts +38 -27
  69. package/src/ai/http.ts +17 -29
  70. package/src/ai/index.ts +11 -5
  71. package/src/ai/inference-calls.ts +6 -4
  72. package/src/ai/maintenance.ts +1 -1
  73. package/src/ai/memory-learning-runs.ts +5 -5
  74. package/src/ai/memory-learning.ts +3 -3
  75. package/src/ai/message-queue.ts +1 -2
  76. package/src/ai/migrate.ts +75 -42
  77. package/src/ai/model-access-migrate.ts +3 -17
  78. package/src/ai/model-access.ts +44 -85
  79. package/src/ai/model-card.tsx +134 -0
  80. package/src/ai/model-catalog.ts +359 -0
  81. package/src/ai/model-chooser-messages.ts +88 -0
  82. package/src/ai/model-chooser.tsx +577 -0
  83. package/src/ai/model-delete.ts +234 -0
  84. package/src/ai/model-fixture.ts +39 -0
  85. package/src/ai/model-icon.tsx +36 -0
  86. package/src/ai/model-overview.ts +53 -0
  87. package/src/ai/model-usage.ts +45 -0
  88. package/src/ai/openai-api-messages.ts +0 -4
  89. package/src/ai/openai-api-slots.ts +10 -2
  90. package/src/ai/openai-api.ts +83 -16
  91. package/src/ai/personal-agent.ts +1 -1
  92. package/src/ai/prefs.ts +12 -10
  93. package/src/ai/projects-routes.ts +2 -1
  94. package/src/ai/projects.ts +13 -12
  95. package/src/ai/protocol.ts +2 -2
  96. package/src/ai/provider-connections.ts +161 -0
  97. package/src/ai/provider-errors.ts +138 -0
  98. package/src/ai/provider-fetch.ts +4 -0
  99. package/src/ai/provider-retry.ts +1 -1
  100. package/src/ai/provider.ts +2 -1
  101. package/src/ai/quota-limits.ts +36 -0
  102. package/src/ai/quota-provider.ts +11 -2
  103. package/src/ai/quota-report.ts +7 -4
  104. package/src/ai/quotas-migrate.ts +1 -0
  105. package/src/ai/quotas.ts +19 -8
  106. package/src/ai/recent-resources.ts +6 -0
  107. package/src/ai/request-headers.ts +14 -87
  108. package/src/ai/resource-refs.ts +6 -2
  109. package/src/ai/routes.ts +17 -18
  110. package/src/ai/runtime.ts +50 -22
  111. package/src/ai/settings.ts +93 -580
  112. package/src/ai/store.ts +45 -23
  113. package/src/ai/stream.ts +1 -1
  114. package/src/ai/structured-runs.ts +2 -2
  115. package/src/ai/structured.ts +29 -46
  116. package/src/ai/task-contracts.ts +25 -5
  117. package/src/ai/task-execution.ts +15 -6
  118. package/src/ai/task-model.ts +9 -0
  119. package/src/ai/transcription.ts +37 -21
  120. package/src/ai/turn-failure.ts +24 -2
  121. package/src/ai/types.ts +50 -62
  122. package/src/ai/ui.tsx +13 -0
  123. package/src/ai/usage.ts +8 -8
  124. package/src/ai/validate.ts +8 -7
  125. package/src/ai/view-block.ts +2 -2
  126. package/src/api/accounts-entities.ts +40 -16
  127. package/src/api/admin-ai-quotas.ts +34 -56
  128. package/src/api/admin-ai-usage.ts +1 -1
  129. package/src/api/admin-ai.ts +173 -0
  130. package/src/api/admin-core-settings.ts +68 -244
  131. package/src/api/admin-directory-shares.ts +55 -0
  132. package/src/api/admin-identity.ts +81 -2
  133. package/src/api/app-access.ts +85 -0
  134. package/src/api/app-approval.ts +3 -1
  135. package/src/api/app-event-delivery.ts +51 -0
  136. package/src/api/app-events.ts +123 -0
  137. package/src/api/app-resource-access.ts +48 -0
  138. package/src/api/auth/account-mismatch.ts +24 -0
  139. package/src/api/auth.ts +26 -3
  140. package/src/api/capabilities.ts +127 -15
  141. package/src/api/capability-streams.ts +2 -5
  142. package/src/api/identity-child-invocation.ts +145 -0
  143. package/src/api/index.ts +11 -2
  144. package/src/api/mcp.ts +180 -5
  145. package/src/api/me.ts +38 -17
  146. package/src/api/pwa-phone.ts +87 -48
  147. package/src/api/pwa.ts +34 -8
  148. package/src/api/resource-links.ts +83 -6
  149. package/src/api/resource-resolve.ts +50 -0
  150. package/src/api/search/schemas.ts +3 -1
  151. package/src/api/search.ts +9 -7
  152. package/src/api/widgets.ts +1 -0
  153. package/src/app-pwa/authority.ts +75 -0
  154. package/src/app-pwa/contracts.ts +177 -0
  155. package/src/app-pwa/core-query-error.ts +20 -0
  156. package/src/app-pwa/index.ts +23 -0
  157. package/src/app-pwa/protocol.ts +27 -0
  158. package/src/app-pwa/schema.ts +59 -0
  159. package/src/app-pwa/service.ts +861 -0
  160. package/src/app-pwa/transaction.ts +338 -0
  161. package/src/app-pwa/transport-input.ts +57 -0
  162. package/src/app-pwa/transport.ts +235 -0
  163. package/src/approvals/messages.ts +2 -2
  164. package/src/browser/app-access-recovery.ts +44 -0
  165. package/src/browser/app-session.ts +14 -3
  166. package/src/browser/cloud-id.ts +86 -0
  167. package/src/browser/live-websocket.ts +5 -0
  168. package/src/browser/pwa-drafts.ts +57 -0
  169. package/src/browser/resource-clipboard.ts +4 -0
  170. package/src/browser/resource-links.ts +52 -0
  171. package/src/browser/resource-picker.tsx +3 -2
  172. package/src/capabilities/calendar-sources.ts +302 -0
  173. package/src/capabilities/executions.ts +10 -3
  174. package/src/capabilities/migrate.ts +4 -0
  175. package/src/capabilities/nested.ts +43 -0
  176. package/src/capabilities/server-invoke.ts +202 -0
  177. package/src/capabilities/server.ts +26 -187
  178. package/src/capabilities/testing.ts +5 -2
  179. package/src/cli/access.ts +6 -5
  180. package/src/cli/account.ts +57 -0
  181. package/src/cli/admin/agents.ts +66 -0
  182. package/src/cli/admin/ai-models.ts +199 -0
  183. package/src/cli/admin/ai-quotas.ts +6 -102
  184. package/src/cli/admin/ai-usage.ts +4 -4
  185. package/src/cli/admin/app-credentials.ts +5 -3
  186. package/src/cli/admin/index.ts +7 -3
  187. package/src/cli/app-access.ts +60 -0
  188. package/src/cli/apps.ts +89 -26
  189. package/src/cli/avatar.ts +64 -0
  190. package/src/cli/capabilities.ts +22 -1
  191. package/src/cli/events.ts +320 -0
  192. package/src/cli/index.ts +1 -1
  193. package/src/config/env.ts +40 -0
  194. package/src/contracts/app-access.ts +52 -5
  195. package/src/contracts/app-approval.ts +8 -1
  196. package/src/contracts/app-details.ts +89 -0
  197. package/src/contracts/app-events.ts +51 -0
  198. package/src/contracts/app.ts +24 -5
  199. package/src/contracts/calendar-source.ts +274 -0
  200. package/src/contracts/calendar-view.ts +35 -0
  201. package/src/contracts/capabilities.ts +164 -3
  202. package/src/contracts/geo-admin.ts +2 -0
  203. package/src/contracts/geo-route.ts +0 -11
  204. package/src/contracts/geo.ts +14 -2
  205. package/src/contracts/index.ts +6 -0
  206. package/src/contracts/installable.ts +40 -0
  207. package/src/contracts/notification-types.ts +36 -9
  208. package/src/contracts/profile.ts +32 -0
  209. package/src/contracts/pwa-builds.ts +14 -0
  210. package/src/contracts/pwa-location.ts +38 -0
  211. package/src/contracts/pwa.ts +24 -3
  212. package/src/contracts/registry.ts +8 -0
  213. package/src/contracts/resource-clipboard.ts +35 -0
  214. package/src/contracts/resource-pages.ts +140 -0
  215. package/src/contracts/resource-resolution.ts +13 -0
  216. package/src/contracts/shared.ts +4 -0
  217. package/src/contracts/user-notifications.ts +2 -0
  218. package/src/events/app-event-delivery.ts +100 -0
  219. package/src/events/app-events.testing.ts +65 -0
  220. package/src/events/app-events.ts +134 -0
  221. package/src/events/core-user-created.ts +24 -0
  222. package/src/events/index.ts +9 -0
  223. package/src/events/testing.ts +2 -0
  224. package/src/index.ts +2 -1
  225. package/src/server/api/respond.ts +8 -3
  226. package/src/server/index.ts +1 -0
  227. package/src/server/middleware/app-access.ts +7 -6
  228. package/src/server/middleware/auth.ts +168 -66
  229. package/src/server/middleware/index.ts +1 -0
  230. package/src/server/middleware/invocation.ts +4 -2
  231. package/src/server/middleware/workload.ts +3 -3
  232. package/src/server/oauth-scope.ts +10 -0
  233. package/src/server/public-storage.ts +106 -0
  234. package/src/server/services/access.ts +8 -0
  235. package/src/services/accounts/app.ts +35 -3
  236. package/src/services/accounts/avatar.ts +342 -35
  237. package/src/services/accounts/base-group.ts +4 -5
  238. package/src/services/accounts/directory-shares.ts +157 -0
  239. package/src/services/accounts/entities.ts +50 -6
  240. package/src/services/accounts/groups.ts +3 -1
  241. package/src/services/accounts/identities.ts +64 -12
  242. package/src/services/accounts/local-groups.ts +1 -1
  243. package/src/services/accounts/users.ts +23 -15
  244. package/src/services/app-access-management.ts +519 -0
  245. package/src/services/app-access.ts +25 -7
  246. package/src/services/app-approval.ts +15 -4
  247. package/src/services/app-details.ts +224 -0
  248. package/src/services/app-discovery.ts +2 -1
  249. package/src/services/app-event-shapes.ts +139 -0
  250. package/src/services/app-events/accept.ts +116 -0
  251. package/src/services/app-events/admin.ts +250 -0
  252. package/src/services/app-events/declarations.ts +187 -0
  253. package/src/services/app-events/deliver.ts +218 -0
  254. package/src/services/app-events.ts +92 -0
  255. package/src/services/config-conflict.ts +11 -0
  256. package/src/services/core-user-events.ts +1 -0
  257. package/src/services/freeipa-config.ts +9 -0
  258. package/src/services/geo/admin.ts +4 -1
  259. package/src/services/geo/ledger.ts +47 -14
  260. package/src/services/geo/route-chain.ts +40 -18
  261. package/src/services/geo/route-estimate.ts +19 -0
  262. package/src/services/geo/route-plan.ts +0 -2
  263. package/src/services/geo/route-service.ts +57 -39
  264. package/src/services/geo/service.ts +51 -25
  265. package/src/services/identity/core-origin.ts +12 -0
  266. package/src/services/identity/index.ts +2 -1
  267. package/src/services/identity/invocation-actor.ts +50 -2
  268. package/src/services/identity/invocation-authority.ts +38 -1
  269. package/src/services/identity/invocation-token.ts +255 -50
  270. package/src/services/identity/issue-invocation.ts +9 -1
  271. package/src/services/identity/models-only-auth.ts +13 -4
  272. package/src/services/identity/nested-invocation.ts +128 -0
  273. package/src/services/image-metadata.ts +65 -1
  274. package/src/services/index.ts +24 -1
  275. package/src/services/ipa/sync.ts +6 -2
  276. package/src/services/ipa/users.ts +4 -1
  277. package/src/services/mandates/policy.ts +19 -12
  278. package/src/services/notifications/browser.ts +4 -0
  279. package/src/services/notifications/catalog.ts +5 -2
  280. package/src/services/notifications/platform.ts +7 -2
  281. package/src/services/notifications/user.ts +18 -7
  282. package/src/services/oauth-tokens.ts +28 -2
  283. package/src/services/object-store.ts +217 -0
  284. package/src/services/providers/local/users.ts +5 -0
  285. package/src/services/pwa-devices.ts +147 -37
  286. package/src/services/service-account-credentials.ts +5 -2
  287. package/src/services/session/index.ts +14 -0
  288. package/src/services/settings/core-settings.ts +91 -47
  289. package/src/services/settings/index.ts +8 -0
  290. package/src/services/settings/versions.ts +26 -0
  291. package/src/shared/ai-models.ts +274 -0
  292. package/src/shared/ai-quotas.ts +2 -2
  293. package/src/shared/ai-usage.ts +2 -2
  294. package/src/shared/index.ts +2 -0
  295. package/src/shared-text/documents.ts +25 -8
  296. package/src/shared-text/log.ts +1 -1
  297. package/src/shared-text/text-diff.ts +75 -0
  298. package/src/ssr/AdminArea.tsx +131 -0
  299. package/src/ssr/AdminLayout.tsx +11 -3
  300. package/src/ssr/AdminSidebar.tsx +7 -7
  301. package/src/ssr/AppAccessDenied.tsx +114 -0
  302. package/src/ssr/AppBadge.tsx +14 -0
  303. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  304. package/src/ssr/AppPwaRuntime.island.tsx +82 -0
  305. package/src/ssr/CloudShell.island.tsx +25 -4
  306. package/src/ssr/Layout.tsx +15 -7
  307. package/src/ssr/PwaLayout.tsx +73 -8
  308. package/src/ssr/PwaPartCount.island.tsx +23 -0
  309. package/src/ssr/PwaRuntime.island.tsx +85 -6
  310. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  311. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  312. package/src/ssr/ShellRuntime.island.tsx +5 -0
  313. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  314. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
  315. package/src/ssr/admin-active-link.ts +16 -0
  316. package/src/ssr/admin-navigation.ts +26 -20
  317. package/src/ssr/app-badges.ts +31 -7
  318. package/src/ssr/app-pwa-document.tsx +16 -0
  319. package/src/ssr/app-pwa-messages.ts +17 -0
  320. package/src/ssr/index.ts +8 -0
  321. package/src/ssr/layout-context.ts +1 -1
  322. package/src/ssr/page-trail-runtime.ts +3 -1
  323. package/src/ssr/platform-messages.ts +9 -1
  324. package/src/ssr/pwa-messages.ts +10 -2
  325. package/src/ssr/rail-navigation.ts +5 -1
  326. package/src/ssr/settings-draft.ts +153 -0
  327. package/src/ssr/shell-data.tsx +2 -0
  328. package/src/styles/global.css +1 -0
  329. package/src/styles/profile-picture.css +270 -0
  330. package/src/styles/utilities-navigation.css +6 -0
  331. package/src/workflows/ai/policy.ts +49 -0
  332. package/src/workflows/ai/runtime.ts +131 -21
  333. package/src/workflows/ai/store.ts +283 -35
  334. package/src/workflows/ai/types.ts +8 -2
  335. package/src/workflows/ai-actions.ts +148 -120
  336. package/src/workflows/authoring.ts +31 -0
  337. package/src/workflows/contracts.ts +1 -0
  338. package/src/workflows/definition.ts +2 -0
  339. package/src/workflows/editor.ts +15 -0
  340. package/src/workflows/index.ts +2 -1
  341. package/src/workflows/language/compiler.ts +34 -3
  342. package/src/workflows/runtime/executor.ts +132 -33
  343. package/src/workflows/runtime/index.ts +2 -0
  344. package/src/workflows/runtime/ports.ts +5 -1
  345. package/src/workflows/store/actions.ts +1 -1
  346. package/src/workflows/store/definitions.ts +5 -0
  347. package/src/workflows/store/runs.ts +5 -3
  348. package/src/workflows/store/worker.ts +7 -2
  349. package/src/ai/model-pricing.ts +0 -43
  350. package/src/ai/model-request-settings.ts +0 -98
  351. package/src/ai/settings-messages.ts +0 -129
  352. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -0,0 +1,861 @@
1
+ import { sql, type TransactionSQL } from "bun";
2
+ import { z } from "zod";
3
+ import { GeoAutocompleteInputSchema } from "../contracts/geo";
4
+ import { isInstallablePath } from "../contracts/installable";
5
+ import { PWA_LIMITS } from "../contracts/pwa";
6
+ import { ratelimit } from "../server/ratelimit";
7
+ import { REQUEST_IDLE_TIMEOUT_SECONDS } from "../server/request-timeout";
8
+ import { getEffectivePermission, hasPermission } from "../server/services/access";
9
+ import { audit } from "../services/audit";
10
+ import { GeoError } from "../services/geo/errors";
11
+ import { coreGeo } from "../services/geo/service";
12
+ import { geoPlan, readGeoConfig } from "../services/geo/settings";
13
+ import { pairingSecret } from "../services/pairing-secret";
14
+ import { get as getSetting } from "../services/settings";
15
+ import { databaseClock, lockWebAuthority } from "./authority";
16
+ import {
17
+ type AppPwaActor,
18
+ type AppPwaDeviceActor,
19
+ type AppPwaDeviceView,
20
+ AppPwaError,
21
+ AppPwaId,
22
+ type AppPwaOptions,
23
+ AppPwaSecret,
24
+ type AppPwaStaffAction,
25
+ type AppPwaVisitActor,
26
+ type AppPwaVisitView,
27
+ type AppPwaWebSignIn,
28
+ APP_PWA_LIMITS as limits,
29
+ parseAppPwaInput as parse,
30
+ pwaFail,
31
+ } from "./contracts";
32
+ import { AppPwaCoreQueryError } from "./core-query-error";
33
+ import { appPwaQr, parseAppPwaQr } from "./protocol";
34
+ import { appPwaSchemaName, setupAppPwa } from "./schema";
35
+ import { withAppPwaTransaction } from "./transaction";
36
+ import { untilAppPwaAbort } from "./transport-input";
37
+
38
+ type Browser = { credential_hash: string; device_id: string | null; expires_at: Date };
39
+ type Device = {
40
+ id: string;
41
+ owner_user_id: string;
42
+ initiator_sid: string;
43
+ auth_epoch: string;
44
+ name: string;
45
+ input_digest: string;
46
+ secret_hash: string;
47
+ browser_hash: string | null;
48
+ state: "pending" | "active" | "revoked";
49
+ qr_expires_at: Date;
50
+ activated_at: Date | null;
51
+ expires_at: Date | null;
52
+ active_visit_id: string | null;
53
+ };
54
+ type Visit = {
55
+ id: string;
56
+ device_id: string;
57
+ input_digest: string;
58
+ secret_hash: string;
59
+ subject: unknown;
60
+ state: "pending" | "active" | "ended" | "revoked";
61
+ browser_hash: string | null;
62
+ qr_expires_at: Date;
63
+ activated_at: Date | null;
64
+ last_used_at: Date | null;
65
+ expires_at: Date | null;
66
+ };
67
+ type Attempt = { route: string; input_digest: string; result: unknown };
68
+
69
+ /** Canonical JSON makes property-order changes harmless while retaining route and validated input binding. */
70
+ export const appPwaDigest = (value: unknown): string => {
71
+ const canonical = (item: unknown): string => {
72
+ if (item === null || typeof item === "string" || typeof item === "boolean") return JSON.stringify(item);
73
+ if (typeof item === "number" && Number.isFinite(item)) return JSON.stringify(item);
74
+ if (Array.isArray(item)) return `[${item.map(canonical).join(",")}]`;
75
+ if (typeof item === "object" && item !== null && Object.getPrototypeOf(item) === Object.prototype) {
76
+ return `{${Object.entries(item)
77
+ .sort(([a], [b]) => a.localeCompare(b, "en"))
78
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`)
79
+ .join(",")}}`;
80
+ }
81
+ return pwaFail("INVALID_INPUT", 400);
82
+ };
83
+ return pairingSecret.hash(canonical(value));
84
+ };
85
+
86
+ const earlier = (...values: Date[]): Date => new Date(Math.min(...values.map((value) => value.getTime())));
87
+ const plus = (time: Date, seconds: number): Date => new Date(time.getTime() + seconds * 1000);
88
+ const deviceView = (device: Device, time: Date): AppPwaDeviceView => ({
89
+ id: device.id,
90
+ name: device.name,
91
+ state:
92
+ device.state !== "revoked" &&
93
+ (device.state === "pending" ? device.qr_expires_at <= time : !device.expires_at || device.expires_at <= time)
94
+ ? "expired"
95
+ : device.state,
96
+ expiresAt: device.expires_at?.toISOString() ?? null,
97
+ });
98
+ const visitView = (visit: Visit, time: Date): AppPwaVisitView => ({
99
+ id: visit.id,
100
+ state:
101
+ (visit.state === "pending" && visit.qr_expires_at <= time) || (visit.state === "active" && !liveVisit(visit, time))
102
+ ? "expired"
103
+ : visit.state,
104
+ expiresAt: visit.expires_at?.toISOString() ?? null,
105
+ idleExpiresAt: visit.last_used_at ? plus(visit.last_used_at, limits.visitIdleSeconds).toISOString() : null,
106
+ });
107
+ const checkDevice = (device: Device, time: Date, epoch: string) => {
108
+ if (device.state === "revoked") return pwaFail("REVOKED", 410);
109
+ if (String(device.auth_epoch) !== String(epoch)) return pwaFail("REVOKED", 410);
110
+ if (device.state !== "active" || !device.expires_at || device.expires_at <= time) return pwaFail("EXPIRED", 410);
111
+ return device.expires_at;
112
+ };
113
+ const checkVisitDeadline = (visit: Visit, time: Date) => {
114
+ if (!visit.expires_at || !visit.last_used_at || visit.expires_at <= time || plus(visit.last_used_at, limits.visitIdleSeconds) <= time)
115
+ return pwaFail("EXPIRED", 410);
116
+ return earlier(visit.expires_at, plus(visit.last_used_at, limits.visitIdleSeconds));
117
+ };
118
+ const checkVisit = (visit: Visit, time: Date) => {
119
+ if (visit.state === "revoked" || visit.state === "ended") return pwaFail("REVOKED", 410);
120
+ if (visit.state !== "active") return pwaFail("EXPIRED", 410);
121
+ return checkVisitDeadline(visit, time);
122
+ };
123
+ const liveVisit = (visit: Visit, time: Date) =>
124
+ visit.state === "active" &&
125
+ visit.expires_at &&
126
+ visit.last_used_at &&
127
+ visit.expires_at > time &&
128
+ plus(visit.last_used_at, limits.visitIdleSeconds) > time;
129
+
130
+ export const createAppPwaService = (options: AppPwaOptions) => {
131
+ if (typeof options.authorizeStaff !== "function" || typeof options.authorizeVisit !== "function")
132
+ throw new Error("App-PWA requires staff and visit-subject authorization");
133
+ const coreQueries = new Set(options.coreQueries ?? []);
134
+ if (!Array.isArray(options.coreQueries ?? []) || [...coreQueries].some((query) => query !== "address.autocomplete"))
135
+ throw new Error("Unsupported App-PWA Core query");
136
+ const db = options.db ?? sql;
137
+ const appId = options.app.id;
138
+ const path = options.app.installable.path;
139
+ const origin = new URL(options.publicOrigin);
140
+ if (origin.protocol !== "https:" || origin.origin !== options.publicOrigin || !isInstallablePath(appId, path))
141
+ throw new Error("App-PWA requires a canonical HTTPS public origin and the declared installable path");
142
+ const schema = appPwaSchemaName(options.schema);
143
+ const tables = {
144
+ browsers: sql(`${schema}._cloud_pwa_browsers`),
145
+ devices: sql(`${schema}._cloud_pwa_devices`),
146
+ visits: sql(`${schema}._cloud_pwa_visits`),
147
+ attempts: sql(`${schema}._cloud_pwa_attempts`),
148
+ };
149
+ const requestedTtl = options.deviceTtlSeconds ?? limits.deviceSeconds;
150
+ if (!Number.isSafeInteger(requestedTtl) || requestedTtl <= 0) throw new Error("Invalid App-PWA device TTL");
151
+ const ttl = Math.min(requestedTtl, limits.deviceSeconds);
152
+ for (const budget of Object.values(options.budgets))
153
+ if (!Number.isSafeInteger(budget) || budget <= 0) throw new Error("Invalid App-PWA budget");
154
+ for (const routes of [options.queries, options.commands, options.deviceOperations]) {
155
+ for (const route of Object.keys(routes ?? {})) if (!/^[a-z][a-z0-9-]*$/.test(route)) throw new Error("Invalid App-PWA operation name");
156
+ }
157
+ const cookieName = `__Secure-cloud-app-pwa-${appId}-${pairingSecret.hash(path).slice(0, 16)}`;
158
+ let ready = false;
159
+ const bounded = <T>(value: T, max = options.budgets.responseBytes): T => {
160
+ const encoded = JSON.stringify(value);
161
+ if (encoded === undefined || new TextEncoder().encode(encoded).byteLength > max) return pwaFail("TOO_LARGE", 413);
162
+ // Refuse undefined, dates, nonfinite values, cycles and non-JSON handler output before storing it.
163
+ appPwaDigest(value);
164
+ return value;
165
+ };
166
+ type BeforeCommit = {
167
+ authorize(check: () => Promise<void>): void;
168
+ deadline(check: (time: Date) => Date): void;
169
+ };
170
+ const transaction = async <T>(
171
+ work: (tx: TransactionSQL, signal: AbortSignal, beforeCommit: BeforeCommit) => Promise<T>,
172
+ signal?: AbortSignal,
173
+ ): Promise<T> => {
174
+ if (!ready) return pwaFail("UNAVAILABLE", 503);
175
+ const authorizations: (() => Promise<void>)[] = [];
176
+ const deadlines: ((time: Date) => Date)[] = [];
177
+ try {
178
+ return await withAppPwaTransaction(
179
+ db,
180
+ signal,
181
+ async (tx, requestSignal) => {
182
+ const result = bounded(
183
+ await work(tx, requestSignal, {
184
+ authorize: (check) => {
185
+ authorizations.push(check);
186
+ },
187
+ deadline: (check) => {
188
+ deadlines.push(check);
189
+ },
190
+ }),
191
+ );
192
+ for (const check of authorizations) await check();
193
+ return result;
194
+ },
195
+ (time) => {
196
+ const checked = deadlines.map((check) => check(time));
197
+ return checked.length ? earlier(...checked) : undefined;
198
+ },
199
+ );
200
+ } catch (error) {
201
+ if (error instanceof AppPwaError) throw error;
202
+ return pwaFail("UNAVAILABLE", 503);
203
+ }
204
+ };
205
+ const record = (
206
+ tx: TransactionSQL,
207
+ event: string,
208
+ actor: AppPwaActor | AppPwaWebSignIn,
209
+ deviceId: string | null,
210
+ visitId: string | null = null,
211
+ ) =>
212
+ audit.record(
213
+ {
214
+ action: `app_pwa.${event}`,
215
+ outcome: "allowed",
216
+ target: { type: "app_pwa", id: appId },
217
+ ...(!("kind" in actor) ? { actor: { userId: actor.userId } } : {}),
218
+ metadata: {
219
+ appId,
220
+ deviceId,
221
+ visitId,
222
+ actorKind: "kind" in actor ? actor.kind : "web",
223
+ ownerUserId: "kind" in actor ? actor.ownerUserId : actor.userId,
224
+ },
225
+ },
226
+ tx,
227
+ );
228
+ const csrf = (credential: string) => pairingSecret.hash(`cloud-app-pwa:csrf:${appId}:${path}:${credential}`);
229
+ const browser = async (tx: TransactionSQL, credential: string, lock = false): Promise<Browser> => {
230
+ parse(AppPwaSecret, credential);
231
+ const [row] = await tx<Browser[]>`SELECT * FROM ${tables.browsers}
232
+ WHERE app_id = ${appId} AND credential_hash = ${pairingSecret.hash(credential)} ${lock ? sql`FOR UPDATE` : sql``}`;
233
+ if (!row || row.expires_at <= (await databaseClock(tx))) return pwaFail("BROWSER_REQUIRED", 401);
234
+ return row;
235
+ };
236
+ const staff = async <T>(
237
+ signIn: AppPwaWebSignIn,
238
+ action: AppPwaStaffAction,
239
+ work: (tx: TransactionSQL, authority: Awaited<ReturnType<typeof lockWebAuthority>>, beforeCommit: BeforeCommit) => Promise<T>,
240
+ signal?: AbortSignal,
241
+ ) => {
242
+ parse(AppPwaId, signIn.userId);
243
+ parse(AppPwaId, signIn.sid);
244
+ return transaction(async (tx, _signal, beforeCommit) => {
245
+ const authority = await lockWebAuthority(tx, signIn);
246
+ beforeCommit.deadline(authority.check);
247
+ beforeCommit.authorize(async () => {
248
+ if (!(await options.authorizeStaff(action, signIn, tx))) pwaFail("FORBIDDEN", 403);
249
+ });
250
+ if (!(await options.authorizeStaff(action, signIn, tx))) return pwaFail("FORBIDDEN", 403);
251
+ const result = await work(tx, authority, beforeCommit);
252
+ await authority.check(await databaseClock(tx));
253
+ return result;
254
+ }, signal);
255
+ };
256
+ const lockDevice = async (tx: TransactionSQL, id: string): Promise<Device> => {
257
+ const [row] = await tx<
258
+ Device[]
259
+ >`SELECT * FROM ${tables.devices} WHERE app_id = ${appId} AND id = ${parse(AppPwaId, id)}::uuid FOR UPDATE`;
260
+ return row ?? pwaFail("NOT_FOUND", 404);
261
+ };
262
+ const lockVisit = async (tx: TransactionSQL, id: string): Promise<Visit> => {
263
+ const [row] = await tx<
264
+ Visit[]
265
+ >`SELECT * FROM ${tables.visits} WHERE app_id = ${appId} AND id = ${parse(AppPwaId, id)}::uuid FOR UPDATE`;
266
+ return row ?? pwaFail("NOT_FOUND", 404);
267
+ };
268
+ const ownedDevice = async (tx: TransactionSQL, signIn: AppPwaWebSignIn, id: string) => {
269
+ const row = await lockDevice(tx, id);
270
+ if (row.owner_user_id !== signIn.userId) return pwaFail("NOT_FOUND", 404);
271
+ return row;
272
+ };
273
+ // Staff can inspect devices from an earlier web session. Report the same
274
+ // initiating-family invalidation that the guest path enforces, without
275
+ // pretending those devices became active again after a fresh sign-in.
276
+ const staffDeviceViews = async (tx: TransactionSQL, devices: Device[], epoch: string): Promise<AppPwaDeviceView[]> => {
277
+ if (!devices.length) return [];
278
+ const families = await tx<
279
+ {
280
+ sid: string;
281
+ auth_epoch: string;
282
+ expires_at: Date;
283
+ revoked_at: Date | null;
284
+ pwa_device_id: string | null;
285
+ key_state: string;
286
+ }[]
287
+ >`SELECT f.sid,f.auth_epoch,f.expires_at,f.revoked_at,f.pwa_device_id,k.state AS key_state
288
+ FROM auth.session_families f JOIN auth.signing_keys k ON k.kid=f.signing_kid
289
+ WHERE f.sid=ANY(${sql.array(
290
+ devices.map((device) => device.initiator_sid),
291
+ "uuid",
292
+ )})`;
293
+ const byId = new Map(families.map((family) => [family.sid, family]));
294
+ const time = await databaseClock(tx);
295
+ return devices.map((device) => {
296
+ const view = deviceView(device, time);
297
+ if (view.state === "revoked" || view.state === "expired") return view;
298
+ const family = byId.get(device.initiator_sid);
299
+ if (
300
+ !family ||
301
+ family.revoked_at ||
302
+ family.pwa_device_id ||
303
+ family.key_state === "revoked" ||
304
+ String(family.auth_epoch) !== String(epoch) ||
305
+ String(device.auth_epoch) !== String(epoch)
306
+ ) {
307
+ return { ...view, state: "revoked" };
308
+ }
309
+ return family.expires_at <= time ? { ...view, state: "expired" } : view;
310
+ });
311
+ };
312
+ const deviceActor = (device: Device): AppPwaDeviceActor => ({
313
+ kind: "device",
314
+ appId,
315
+ deviceId: device.id,
316
+ ownerUserId: device.owner_user_id,
317
+ });
318
+ const visitActor = (device: Device, visit: Visit): AppPwaVisitActor => ({
319
+ ...deviceActor(device),
320
+ kind: "visit",
321
+ visitId: visit.id,
322
+ subject: parse(options.subject, visit.subject),
323
+ });
324
+ const guest = async <T>(
325
+ credential: string,
326
+ work: (tx: TransactionSQL, device: Device, signal: AbortSignal, beforeCommit: BeforeCommit) => Promise<T>,
327
+ signal?: AbortSignal,
328
+ ) =>
329
+ transaction(async (tx, requestSignal, beforeCommit) => {
330
+ // Snapshot identifies the owner only; every authority row is locked and checked afterwards.
331
+ const snapshot = await browser(tx, credential);
332
+ if (!snapshot.device_id) return pwaFail("BROWSER_REQUIRED", 401);
333
+ const [identity] = await tx<Device[]>`SELECT * FROM ${tables.devices} WHERE app_id = ${appId} AND id = ${snapshot.device_id}::uuid`;
334
+ if (!identity) return pwaFail("BROWSER_REQUIRED", 401);
335
+ const authority = await lockWebAuthority(tx, { userId: identity.owner_user_id, sid: identity.initiator_sid });
336
+ const device = await lockDevice(tx, identity.id);
337
+ const binding = await browser(tx, credential, true);
338
+ if (binding.device_id !== device.id || device.browser_hash !== binding.credential_hash) return pwaFail("BROWSER_REQUIRED", 401);
339
+ checkDevice(device, await databaseClock(tx), authority.user.auth_epoch);
340
+ beforeCommit.deadline((time) => {
341
+ const authorityExpiry = authority.check(time);
342
+ const deviceExpiry = checkDevice(device, time, authority.user.auth_epoch);
343
+ if (binding.expires_at <= time) return pwaFail("EXPIRED", 410);
344
+ return earlier(authorityExpiry, deviceExpiry, binding.expires_at);
345
+ });
346
+ const result = await work(tx, device, requestSignal, beforeCommit);
347
+ const time = await databaseClock(tx);
348
+ await authority.check(time);
349
+ checkDevice(device, time, authority.user.auth_epoch);
350
+ if (binding.expires_at <= time) return pwaFail("EXPIRED", 410);
351
+ return result;
352
+ }, signal);
353
+ const boundVisit = async (tx: TransactionSQL, device: Device, visitId: string) => {
354
+ const visit = await lockVisit(tx, visitId);
355
+ if (visit.device_id !== device.id || visit.browser_hash !== device.browser_hash) return pwaFail("NOT_FOUND", 404);
356
+ return visit;
357
+ };
358
+ const coreActor = async (tx: TransactionSQL, device: Device, beforeCommit: BeforeCommit, visitId?: string): Promise<AppPwaActor> => {
359
+ const currentVisit = visitId ?? device.active_visit_id;
360
+ if (!currentVisit) return deviceActor(device);
361
+ const visit = await boundVisit(tx, device, currentVisit);
362
+ if (device.active_visit_id !== visit.id) return pwaFail("REVOKED", 410);
363
+ // Device mode resumes after a visit ends. Explicit visit requests never fall back.
364
+ if (!visitId && !liveVisit(visit, await databaseClock(tx))) return deviceActor(device);
365
+ beforeCommit.deadline((time) => checkVisit(visit, time));
366
+ checkVisit(visit, await databaseClock(tx));
367
+ return visitActor(device, visit);
368
+ };
369
+ const recordCoreQuery = (actor: AppPwaActor, outcome: "allowed" | "denied" | "failed", code?: string, tx?: TransactionSQL) =>
370
+ audit.record(
371
+ {
372
+ action: "app_pwa.core.query",
373
+ outcome,
374
+ target: { type: "core.query", id: "address.autocomplete" },
375
+ ...(code ? { error: { code } } : {}),
376
+ metadata: {
377
+ appId,
378
+ subject: { type: "user", userId: actor.ownerUserId },
379
+ via: { kind: actor.kind, appId, deviceId: actor.deviceId, ...(actor.kind === "visit" ? { visitId: actor.visitId } : {}) },
380
+ },
381
+ },
382
+ tx,
383
+ );
384
+ const readAttempt = async (tx: TransactionSQL, visitId: string, attemptId: string) => {
385
+ const [row] = await tx<Attempt[]>`SELECT route, input_digest, result FROM ${tables.attempts}
386
+ WHERE app_id = ${appId} AND visit_id = ${visitId}::uuid AND attempt_id = ${parse(AppPwaId, attemptId)}::uuid`;
387
+ return row;
388
+ };
389
+
390
+ return {
391
+ path,
392
+ appId,
393
+ publicOrigin: origin.origin,
394
+ cookieName,
395
+ budgets: options.budgets,
396
+ csrf,
397
+ /** Lifecycle setup; a rejected migration prevents all service operations. */
398
+ setup: async () => {
399
+ await setupAppPwa(db, schema);
400
+ ready = true;
401
+ },
402
+ /** No caller-selected credential. An existing credential can only continue its existing browser. */
403
+ bootstrap: (existingCredential?: string, signal?: AbortSignal) =>
404
+ transaction(async (tx, _signal, beforeCommit) => {
405
+ // Unknown/malformed hints never become a stored credential. A still-live
406
+ // binding survives bootstrap; terminal authority gets a fresh random browser.
407
+ if (existingCredential && AppPwaSecret.safeParse(existingCredential).success) {
408
+ const [binding] = await tx<Browser[]>`SELECT * FROM ${tables.browsers}
409
+ WHERE app_id = ${appId} AND credential_hash = ${pairingSecret.hash(existingCredential)}`;
410
+ let live = !!binding && binding.expires_at > (await databaseClock(tx));
411
+ if (live && binding?.device_id) {
412
+ try {
413
+ const [identity] = await tx<
414
+ Device[]
415
+ >`SELECT * FROM ${tables.devices} WHERE app_id = ${appId} AND id = ${binding.device_id}::uuid`;
416
+ if (!identity) return pwaFail("BROWSER_REQUIRED", 401);
417
+ const authority = await lockWebAuthority(tx, { userId: identity.owner_user_id, sid: identity.initiator_sid });
418
+ const device = await lockDevice(tx, identity.id);
419
+ const current = await browser(tx, existingCredential, true);
420
+ const check = (time: Date) => {
421
+ const authorityExpiry = authority.check(time);
422
+ const deviceExpiry = checkDevice(device, time, authority.user.auth_epoch);
423
+ if (current.device_id !== device.id || device.browser_hash !== current.credential_hash || current.expires_at <= time)
424
+ pwaFail("BROWSER_REQUIRED", 401);
425
+ return earlier(authorityExpiry, deviceExpiry, current.expires_at);
426
+ };
427
+ await check(await databaseClock(tx));
428
+ beforeCommit.deadline(check);
429
+ } catch (error) {
430
+ if (
431
+ !(error instanceof AppPwaError) ||
432
+ !["EXPIRED", "REVOKED", "WEB_SESSION_REQUIRED", "BROWSER_REQUIRED"].includes(error.code)
433
+ )
434
+ throw error;
435
+ live = false;
436
+ }
437
+ }
438
+ if (live && binding) {
439
+ beforeCommit.deadline((time) => {
440
+ if (binding.expires_at <= time) pwaFail("BROWSER_REQUIRED", 401);
441
+ return binding.expires_at;
442
+ });
443
+ return { credential: existingCredential, csrf: csrf(existingCredential) };
444
+ }
445
+ }
446
+ const credential = pairingSecret.create();
447
+ await tx`INSERT INTO ${tables.browsers}(app_id, credential_hash, expires_at)
448
+ VALUES (${appId}, ${pairingSecret.hash(credential)}, ${plus(await databaseClock(tx), limits.deviceSeconds)})`;
449
+ return { credential, csrf: csrf(credential) };
450
+ }, signal),
451
+ issueDevice: (signIn: AppPwaWebSignIn, input: { requestId: string; name: string }, signal?: AbortSignal) =>
452
+ staff(
453
+ signIn,
454
+ "issue-device",
455
+ async (tx, authority) => {
456
+ parse(z.strictObject({ requestId: AppPwaId, name: z.string().trim().min(1).max(PWA_LIMITS.nameMaxLength) }), input);
457
+ const digest = appPwaDigest(input);
458
+ const [existing] = await tx<Device[]>`SELECT * FROM ${tables.devices} WHERE app_id = ${appId}
459
+ AND owner_user_id = ${signIn.userId}::uuid AND initiator_sid = ${signIn.sid}::uuid AND request_id = ${input.requestId}::uuid FOR UPDATE`;
460
+ if (existing && existing.input_digest !== digest) return pwaFail("ATTEMPT_CONFLICT", 409);
461
+ const time = await databaseClock(tx);
462
+ if (existing && existing.state !== "pending")
463
+ return { ...deviceView(existing, time), qr: null, qrExpiresAt: existing.qr_expires_at.toISOString() };
464
+ if (existing && existing.qr_expires_at <= time) return pwaFail("EXPIRED", 410);
465
+ // A lost issuance response rotates the unconsumed QR on the SAME row; no raw QR is stored.
466
+ const secret = pairingSecret.create();
467
+ const id = existing?.id ?? crypto.randomUUID();
468
+ const expires = existing?.qr_expires_at ?? earlier(plus(time, limits.qrSeconds), authority.family.expires_at);
469
+ if (existing)
470
+ await tx`UPDATE ${tables.devices} SET secret_hash = ${pairingSecret.hash(secret)} WHERE app_id = ${appId} AND id = ${id}::uuid`;
471
+ else {
472
+ await tx`INSERT INTO ${tables.devices}(app_id,id,request_id,input_digest,owner_user_id,initiator_sid,auth_epoch,name,secret_hash,state,issued_at,qr_expires_at)
473
+ VALUES (${appId},${id}::uuid,${input.requestId}::uuid,${digest},${signIn.userId}::uuid,${signIn.sid}::uuid,${authority.user.auth_epoch},${input.name},${pairingSecret.hash(secret)},'pending',${time},${expires})`;
474
+ await record(tx, "device.issued", signIn, id);
475
+ }
476
+ return {
477
+ id,
478
+ name: input.name,
479
+ state: "pending",
480
+ expiresAt: null,
481
+ qr: appPwaQr(origin.origin, path, { kind: "device", secret }),
482
+ qrExpiresAt: expires.toISOString(),
483
+ };
484
+ },
485
+ signal,
486
+ ),
487
+ claimDevice: (credential: string, qr: string, signal?: AbortSignal) =>
488
+ transaction(async (tx, _signal, beforeCommit) => {
489
+ const parsed = parseAppPwaQr(qr, origin.origin, path);
490
+ if (parsed?.kind !== "device") return pwaFail("INVALID_QR", 400);
491
+ await browser(tx, credential);
492
+ const [identity] = await tx<
493
+ Device[]
494
+ >`SELECT * FROM ${tables.devices} WHERE app_id = ${appId} AND secret_hash = ${pairingSecret.hash(parsed.secret)}`;
495
+ if (!identity) return pwaFail("INVALID_QR", 400);
496
+ const authority = await lockWebAuthority(tx, { userId: identity.owner_user_id, sid: identity.initiator_sid });
497
+ const device = await lockDevice(tx, identity.id);
498
+ const binding = await browser(tx, credential, true);
499
+ beforeCommit.deadline((time) => {
500
+ const authorityExpiry = authority.check(time);
501
+ const deviceExpiry = checkDevice(device, time, authority.user.auth_epoch);
502
+ if (binding.expires_at <= time) pwaFail("EXPIRED", 410);
503
+ return earlier(authorityExpiry, deviceExpiry, binding.expires_at);
504
+ });
505
+ if (!pairingSecret.matches(parsed.secret, device.secret_hash)) return pwaFail("INVALID_QR", 400);
506
+ const time = await databaseClock(tx);
507
+ if (device.state !== "pending") {
508
+ if (device.browser_hash !== binding.credential_hash || binding.device_id !== device.id) return pwaFail("ALREADY_CLAIMED", 409);
509
+ checkDevice(device, time, authority.user.auth_epoch);
510
+ } else {
511
+ if (device.qr_expires_at <= time) return pwaFail("EXPIRED", 410);
512
+ // A terminal binding must bootstrap a new server-generated credential.
513
+ // Never transfer the old browser's authority to a new device in place.
514
+ if (binding.device_id) return pwaFail("BROWSER_REQUIRED", 401);
515
+ const expires = earlier(plus(time, ttl), authority.family.expires_at, binding.expires_at);
516
+ await tx`UPDATE ${tables.devices} SET browser_hash = ${binding.credential_hash}, state = 'active', activated_at = ${time}, expires_at = ${expires}
517
+ WHERE app_id = ${appId} AND id = ${device.id}::uuid`;
518
+ await tx`UPDATE ${tables.browsers} SET device_id = ${device.id}::uuid WHERE app_id = ${appId} AND credential_hash = ${binding.credential_hash}`;
519
+ device.state = "active";
520
+ device.expires_at = expires;
521
+ await record(tx, "device.claimed", deviceActor(device), device.id);
522
+ }
523
+ const finish = await databaseClock(tx);
524
+ await authority.check(finish);
525
+ checkDevice(device, finish, authority.user.auth_epoch);
526
+ return deviceView(device, finish);
527
+ }, signal),
528
+ listDevices: (signIn: AppPwaWebSignIn, after?: string, signal?: AbortSignal) =>
529
+ staff(
530
+ signIn,
531
+ "list-devices",
532
+ async (tx, authority) => {
533
+ if (after) parse(AppPwaId, after);
534
+ const rows = await tx<Device[]>`SELECT * FROM ${tables.devices} WHERE app_id = ${appId} AND owner_user_id = ${signIn.userId}::uuid
535
+ ${after ? sql`AND id > ${after}::uuid` : sql``} ORDER BY id LIMIT ${options.budgets.listPageSize}`;
536
+ return {
537
+ items: await staffDeviceViews(tx, rows, authority.user.auth_epoch),
538
+ next: rows.length === options.budgets.listPageSize ? (rows.at(-1)?.id ?? null) : null,
539
+ };
540
+ },
541
+ signal,
542
+ ),
543
+ deviceStatus: (signIn: AppPwaWebSignIn, id: string, signal?: AbortSignal) =>
544
+ staff(
545
+ signIn,
546
+ "device-status",
547
+ async (tx, authority) => {
548
+ const views = await staffDeviceViews(tx, [await ownedDevice(tx, signIn, id)], authority.user.auth_epoch);
549
+ return views[0] ?? pwaFail("NOT_FOUND", 404);
550
+ },
551
+ signal,
552
+ ),
553
+ revokeDevice: (signIn: AppPwaWebSignIn, id: string, signal?: AbortSignal) =>
554
+ staff(
555
+ signIn,
556
+ "revoke-device",
557
+ async (tx) => {
558
+ const device = await ownedDevice(tx, signIn, id);
559
+ if (device.state !== "revoked") {
560
+ await tx`UPDATE ${tables.devices} SET state = 'revoked' WHERE app_id = ${appId} AND id = ${device.id}::uuid`;
561
+ await record(tx, "device.revoked", signIn, device.id);
562
+ }
563
+ return { state: "revoked" };
564
+ },
565
+ signal,
566
+ ),
567
+ issueVisit: (signIn: AppPwaWebSignIn, input: { requestId: string; deviceId: string; subject: unknown }, signal?: AbortSignal) =>
568
+ staff(
569
+ signIn,
570
+ "issue-visit",
571
+ async (tx, authority, beforeCommit) => {
572
+ parse(AppPwaId, input.requestId);
573
+ const subject = bounded(parse(options.subject, input.subject), options.budgets.requestBytes);
574
+ const device = await ownedDevice(tx, signIn, input.deviceId);
575
+ beforeCommit.deadline((time) => checkDevice(device, time, authority.user.auth_epoch));
576
+ // Ownership belongs to the employee, not the browser used to open
577
+ // this staff screen. The pairing family's authority must still live.
578
+ if (device.initiator_sid !== signIn.sid) {
579
+ const initiating = await lockWebAuthority(tx, { userId: device.owner_user_id, sid: device.initiator_sid });
580
+ beforeCommit.deadline(initiating.check);
581
+ }
582
+ checkDevice(device, await databaseClock(tx), authority.user.auth_epoch);
583
+ const authorizeSubject = async () => {
584
+ if (!(await options.authorizeVisit(signIn, subject, tx))) pwaFail("FORBIDDEN", 403);
585
+ };
586
+ await authorizeSubject();
587
+ beforeCommit.authorize(authorizeSubject);
588
+ const digest = appPwaDigest({ deviceId: device.id, subject });
589
+ const [existing] = await tx<
590
+ Visit[]
591
+ >`SELECT * FROM ${tables.visits} WHERE app_id = ${appId} AND device_id = ${device.id}::uuid AND request_id = ${input.requestId}::uuid FOR UPDATE`;
592
+ if (existing && existing.input_digest !== digest) return pwaFail("ATTEMPT_CONFLICT", 409);
593
+ const time = await databaseClock(tx);
594
+ if (existing && existing.state !== "pending")
595
+ return { ...visitView(existing, time), qr: null, qrExpiresAt: existing.qr_expires_at.toISOString() };
596
+ if (existing && existing.qr_expires_at <= time) return pwaFail("EXPIRED", 410);
597
+ const secret = pairingSecret.create();
598
+ const id = existing?.id ?? crypto.randomUUID();
599
+ const expires = existing?.qr_expires_at ?? earlier(plus(time, limits.qrSeconds), device.expires_at ?? time);
600
+ if (existing)
601
+ await tx`UPDATE ${tables.visits} SET secret_hash = ${pairingSecret.hash(secret)} WHERE app_id = ${appId} AND id = ${id}::uuid`;
602
+ else {
603
+ await tx`INSERT INTO ${tables.visits}(app_id,id,device_id,request_id,input_digest,secret_hash,subject,state,issued_at,qr_expires_at)
604
+ VALUES (${appId},${id}::uuid,${device.id}::uuid,${input.requestId}::uuid,${digest},${pairingSecret.hash(secret)},${JSON.stringify(subject)}::text::jsonb,'pending',${time},${expires})`;
605
+ await record(tx, "visit.issued", signIn, device.id, id);
606
+ }
607
+ checkDevice(device, await databaseClock(tx), authority.user.auth_epoch);
608
+ return {
609
+ id,
610
+ state: "pending",
611
+ expiresAt: null,
612
+ idleExpiresAt: null,
613
+ qr: appPwaQr(origin.origin, path, { kind: "visit", secret }),
614
+ qrExpiresAt: expires.toISOString(),
615
+ };
616
+ },
617
+ signal,
618
+ ),
619
+ claimVisit: (credential: string, qr: string, signal?: AbortSignal) =>
620
+ guest(
621
+ credential,
622
+ async (tx, device, _signal, beforeCommit) => {
623
+ const parsed = parseAppPwaQr(qr, origin.origin, path);
624
+ if (parsed?.kind !== "visit") return pwaFail("INVALID_QR", 400);
625
+ const [identity] = await tx<
626
+ Visit[]
627
+ >`SELECT * FROM ${tables.visits} WHERE app_id = ${appId} AND secret_hash = ${pairingSecret.hash(parsed.secret)}`;
628
+ if (!identity || identity.device_id !== device.id) return pwaFail("INVALID_QR", 400);
629
+ const visit = await lockVisit(tx, identity.id);
630
+ beforeCommit.deadline((time) => checkVisit(visit, time));
631
+ const time = await databaseClock(tx);
632
+ if (!pairingSecret.matches(parsed.secret, visit.secret_hash)) return pwaFail("INVALID_QR", 400);
633
+ if (visit.state !== "pending") {
634
+ if (visit.browser_hash !== device.browser_hash) return pwaFail("ALREADY_CLAIMED", 409);
635
+ checkVisit(visit, time);
636
+ if (device.active_visit_id !== visit.id) return pwaFail("REVOKED", 410);
637
+ } else {
638
+ if (visit.qr_expires_at <= time) return pwaFail("EXPIRED", 410);
639
+ if (device.active_visit_id) {
640
+ const current = await lockVisit(tx, device.active_visit_id);
641
+ if (liveVisit(current, time)) return pwaFail("ACTIVE_VISIT", 409);
642
+ }
643
+ visit.expires_at = earlier(plus(time, limits.visitSeconds), device.expires_at ?? time);
644
+ visit.last_used_at = time;
645
+ visit.state = "active";
646
+ await tx`UPDATE ${tables.visits} SET state = 'active', browser_hash = ${device.browser_hash}, activated_at = ${time}, last_used_at = ${time}, expires_at = ${visit.expires_at}
647
+ WHERE app_id = ${appId} AND id = ${visit.id}::uuid`;
648
+ await tx`UPDATE ${tables.devices} SET active_visit_id = ${visit.id}::uuid WHERE app_id = ${appId} AND id = ${device.id}::uuid`;
649
+ await record(tx, "visit.claimed", deviceActor(device), device.id, visit.id);
650
+ }
651
+ checkVisit(visit, await databaseClock(tx));
652
+ return visitView(visit, await databaseClock(tx));
653
+ },
654
+ signal,
655
+ ),
656
+ status: (credential: string, signal?: AbortSignal) =>
657
+ guest(
658
+ credential,
659
+ async (tx, device, _signal, beforeCommit) => {
660
+ if (!device.active_visit_id) return { device: deviceView(device, await databaseClock(tx)), visit: null };
661
+ const visit = await boundVisit(tx, device, device.active_visit_id);
662
+ if (!liveVisit(visit, await databaseClock(tx))) return { device: deviceView(device, await databaseClock(tx)), visit: null };
663
+ beforeCommit.deadline((time) => checkVisit(visit, time));
664
+ return { device: deviceView(device, await databaseClock(tx)), visit: visitView(visit, await databaseClock(tx)) };
665
+ },
666
+ signal,
667
+ ),
668
+ coreQuery: async (
669
+ credential: string,
670
+ visitId: string | undefined,
671
+ query: string,
672
+ input: unknown,
673
+ signal?: AbortSignal,
674
+ locale?: string,
675
+ ) => {
676
+ const requestSignal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(REQUEST_IDLE_TIMEOUT_SECONDS * 1000)]);
677
+ return untilAppPwaAbort(requestSignal, async () => {
678
+ if (query !== "address.autocomplete" || !coreQueries.has(query)) return pwaFail("FORBIDDEN", 403);
679
+ const value = parse(GeoAutocompleteInputSchema, bounded(input, options.budgets.requestBytes));
680
+ const actor = await guest(
681
+ credential,
682
+ (tx, device, _signal, beforeCommit) => coreActor(tx, device, beforeCommit, visitId),
683
+ requestSignal,
684
+ );
685
+ try {
686
+ // No pairing transaction or settings lock spans a pool checkout or provider request.
687
+ // Bound each device/visit by both the operator's request budget and the Geo plan's RPS.
688
+ const [requestRate, geoConfig] = await Promise.all([getSetting<number>("security.rate_limit_per_second"), readGeoConfig()]);
689
+ if (requestSignal.aborted) return pwaFail("UNAVAILABLE", 503);
690
+ const limiter = ratelimit({ id: `app-pwa:${appId}:core`, limit: Math.min(requestRate, geoPlan(geoConfig).rps) });
691
+ if ((await limiter.check(`device:${actor.deviceId}`)).limited) return pwaFail("RATE_LIMITED", 429);
692
+ if (actor.kind === "visit" && (await limiter.check(`visit:${actor.visitId}`)).limited) return pwaFail("RATE_LIMITED", 429);
693
+ if (requestSignal.aborted) return pwaFail("UNAVAILABLE", 503);
694
+ const result = bounded(await coreGeo.autocompleteForAppPwa(value, { actor, signal: requestSignal, locale }));
695
+ await guest(
696
+ credential,
697
+ async (tx, device, _signal, beforeCommit) => {
698
+ const current = await coreActor(tx, device, beforeCommit, actor.kind === "visit" ? actor.visitId : undefined);
699
+ // A newly opened visit changes device-mode authority; retry with its current status.
700
+ if (current.kind !== actor.kind) return pwaFail("UNAVAILABLE", 503);
701
+ if (current.deviceId !== actor.deviceId || current.ownerUserId !== actor.ownerUserId) return pwaFail("REVOKED", 410);
702
+ await recordCoreQuery(current, "allowed", undefined, tx);
703
+ return null;
704
+ },
705
+ requestSignal,
706
+ );
707
+ return result;
708
+ } catch (error) {
709
+ const code = error instanceof AppPwaError || error instanceof GeoError ? error.code : "UNAVAILABLE";
710
+ await recordCoreQuery(
711
+ actor,
712
+ error instanceof AppPwaError || (error instanceof GeoError && error.status < 500) ? "denied" : "failed",
713
+ code,
714
+ );
715
+ if (error instanceof GeoError) throw new AppPwaCoreQueryError(error);
716
+ if (error instanceof AppPwaError) throw error;
717
+ return pwaFail("UNAVAILABLE", 503);
718
+ }
719
+ });
720
+ },
721
+ query: (credential: string, visitId: string, route: string, input: unknown, signal?: AbortSignal) =>
722
+ guest(
723
+ credential,
724
+ async (tx, device, requestSignal, beforeCommit) => {
725
+ const operation = Object.hasOwn(options.queries ?? {}, route) ? options.queries?.[route] : undefined;
726
+ if (!operation) return pwaFail("NOT_FOUND", 404);
727
+ const visit = await boundVisit(tx, device, visitId);
728
+ if (device.active_visit_id !== visit.id) return pwaFail("REVOKED", 410);
729
+ beforeCommit.deadline((time) => checkVisit(visit, time));
730
+ checkVisit(visit, await databaseClock(tx));
731
+ const result = bounded(
732
+ await operation.run(
733
+ { actor: visitActor(device, visit), tx, signal: requestSignal },
734
+ bounded(input, options.budgets.requestBytes),
735
+ ),
736
+ );
737
+ checkVisit(visit, await databaseClock(tx));
738
+ return result;
739
+ },
740
+ signal,
741
+ ),
742
+ command: (credential: string, visitId: string, route: string, attemptId: string, input: unknown, signal?: AbortSignal) =>
743
+ guest(
744
+ credential,
745
+ async (tx, device, requestSignal, beforeCommit) => {
746
+ const operation = Object.hasOwn(options.commands ?? {}, route) ? options.commands?.[route] : undefined;
747
+ if (!operation) return pwaFail("NOT_FOUND", 404);
748
+ const visit = await boundVisit(tx, device, visitId);
749
+ const digest = appPwaDigest(operation.parse(bounded(input, options.budgets.requestBytes)));
750
+ const stored = await readAttempt(tx, visit.id, attemptId);
751
+ const timeBefore = await databaseClock(tx);
752
+ const live = device.active_visit_id === visit.id && liveVisit(visit, timeBefore);
753
+ if (stored) {
754
+ if (stored.route !== route || stored.input_digest !== digest) return pwaFail("ATTEMPT_CONFLICT", 409);
755
+ if (!live) return { state: "committed" as const, completed: visit.state === "ended", replayed: true, result: null };
756
+ beforeCommit.deadline((time) => checkVisit(visit, time));
757
+ return { result: stored.result, replayed: true };
758
+ }
759
+ if (device.active_visit_id !== visit.id) return pwaFail("REVOKED", 410);
760
+ checkVisit(visit, timeBefore);
761
+ beforeCommit.deadline((time) => checkVisit(visit, time));
762
+ let ended = false;
763
+ const actor = visitActor(device, visit);
764
+ const result = bounded(
765
+ await operation.run(
766
+ {
767
+ actor,
768
+ tx,
769
+ signal: requestSignal,
770
+ endVisit: () => {
771
+ ended = true;
772
+ },
773
+ },
774
+ input,
775
+ ),
776
+ );
777
+ // Expiry after domain locks/work rolls back domain writes as well as the attempt.
778
+ const time = await databaseClock(tx);
779
+ checkVisit(visit, time);
780
+ await tx`INSERT INTO ${tables.attempts}(app_id,visit_id,attempt_id,route,input_digest,result)
781
+ VALUES (${appId},${visit.id}::uuid,${parse(AppPwaId, attemptId)}::uuid,${route},${digest},${JSON.stringify(result)}::text::jsonb)`;
782
+ await tx`UPDATE ${tables.visits} SET last_used_at = ${time}, state = ${ended ? "ended" : "active"} WHERE app_id = ${appId} AND id = ${visit.id}::uuid`;
783
+ await record(tx, ended ? "visit.completed" : "visit.command", actor, device.id, visit.id);
784
+ checkVisit(visit, await databaseClock(tx));
785
+ // A completing command ends content access immediately, including this response.
786
+ return ended ? { state: "committed" as const, completed: true, result: null, replayed: false } : { result, replayed: false };
787
+ },
788
+ signal,
789
+ ),
790
+ attempt: (credential: string, visitId: string, attemptId: string, signal?: AbortSignal) =>
791
+ guest(
792
+ credential,
793
+ async (tx, device) => {
794
+ const visit = await boundVisit(tx, device, visitId);
795
+ const stored = await readAttempt(tx, visit.id, attemptId);
796
+ // Reconciliation is status-only. Old customer attempts can never reveal
797
+ // stored domain output, even when a new visit now occupies this device.
798
+ return stored ? { state: "committed" as const, completed: visit.state === "ended" } : { state: "unknown" as const };
799
+ },
800
+ signal,
801
+ ),
802
+ endVisit: (credential: string, visitId: string, signal?: AbortSignal) =>
803
+ guest(
804
+ credential,
805
+ async (tx, device) => {
806
+ const visit = await boundVisit(tx, device, visitId);
807
+ if (visit.state === "active") {
808
+ await tx`UPDATE ${tables.visits} SET state = 'revoked' WHERE app_id = ${appId} AND id = ${visit.id}::uuid`;
809
+ await record(tx, "visit.revoked", visitActor(device, visit), device.id, visit.id);
810
+ }
811
+ return { state: "revoked" };
812
+ },
813
+ signal,
814
+ ),
815
+ revokeVisit: (signIn: AppPwaWebSignIn, deviceId: string, visitId: string, signal?: AbortSignal) =>
816
+ staff(
817
+ signIn,
818
+ "revoke-visit",
819
+ async (tx) => {
820
+ const device = await ownedDevice(tx, signIn, deviceId);
821
+ const visit = await lockVisit(tx, visitId);
822
+ if (visit.device_id !== device.id) return pwaFail("NOT_FOUND", 404);
823
+ if (visit.state !== "revoked" && visit.state !== "ended") {
824
+ await tx`UPDATE ${tables.visits} SET state = 'revoked' WHERE app_id = ${appId} AND id = ${visit.id}::uuid`;
825
+ await record(tx, "visit.revoked", signIn, device.id, visit.id);
826
+ }
827
+ return { state: "revoked" };
828
+ },
829
+ signal,
830
+ ),
831
+ deviceOperation: (credential: string, route: string, input: unknown, signal?: AbortSignal) =>
832
+ guest(
833
+ credential,
834
+ async (tx, device, requestSignal, beforeCommit) => {
835
+ const operation = Object.hasOwn(options.deviceOperations ?? {}, route) ? options.deviceOperations?.[route] : undefined;
836
+ const ceiling = Object.hasOwn(options.devicePermissions ?? {}, route) ? options.devicePermissions?.[route] : undefined;
837
+ if (!operation || !ceiling || !hasPermission(ceiling, operation.permission)) return pwaFail("FORBIDDEN", 403);
838
+ const context = { actor: deviceActor(device), tx, signal: requestSignal };
839
+ // getEffectivePermission reads these canonical tables, including nested
840
+ // membership. SHARE also covers missing membership rows/INSERTs. The
841
+ // app's accessIds resolver must lock its resource/junction authority.
842
+ await tx`LOCK TABLE auth.access, auth.groups, auth.user_groups_v2, auth.group_groups_v2 IN SHARE MODE`;
843
+ const checkPermission = async () => {
844
+ const ids = await operation.accessIds(context, bounded(input, options.budgets.requestBytes));
845
+ if (ids.length > options.budgets.maxAccessIds) return pwaFail("TOO_LARGE", 413);
846
+ ids.forEach((id) => parse(AppPwaId, id));
847
+ const current = await getEffectivePermission({ accessIds: ids, subject: { type: "user", userId: device.owner_user_id } }, tx);
848
+ if (!hasPermission(current, operation.permission)) return pwaFail("FORBIDDEN", 403);
849
+ };
850
+ await checkPermission();
851
+ beforeCommit.authorize(checkPermission);
852
+ const result = bounded(await operation.run(context, input));
853
+ await record(tx, "device.operation", context.actor, device.id);
854
+ return result;
855
+ },
856
+ signal,
857
+ ),
858
+ };
859
+ };
860
+
861
+ export type AppPwaService = ReturnType<typeof createAppPwaService>;