@k2b/cloud 0.39.0 → 0.41.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 (237) hide show
  1. package/README.md +8 -0
  2. package/package.json +11 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +217 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +49 -18
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/heartbeat.ts +203 -14
  12. package/src/_internal/registry.ts +28 -1
  13. package/src/_internal/runtime-watcher.ts +6 -0
  14. package/src/_internal/setup-lock.ts +103 -0
  15. package/src/_internal/static-assets.ts +52 -3
  16. package/src/_internal/status-preserving-ssr.ts +2 -0
  17. package/src/access/PermissionEditor.tsx +2 -2
  18. package/src/account/EntitySearch.tsx +1 -1
  19. package/src/ai/action-review.ts +101 -0
  20. package/src/ai/background-review.ts +72 -0
  21. package/src/ai/capabilities.ts +29 -11
  22. package/src/ai/capability-execution.ts +76 -43
  23. package/src/ai/capability-result.ts +95 -0
  24. package/src/ai/chat/action-card.ts +464 -0
  25. package/src/ai/chat/action-messages.ts +129 -0
  26. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  27. package/src/ai/chat/action-question.fixtures.ts +733 -0
  28. package/src/ai/chat/action-question.tsx +680 -0
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/blocks.tsx +19 -25
  31. package/src/ai/chat/message-actions.tsx +6 -0
  32. package/src/ai/chat/tool-action-words.ts +31 -0
  33. package/src/ai/chat/tool-action.tsx +456 -0
  34. package/src/ai/chat/turn-view.tsx +10 -160
  35. package/src/ai/chat/view-block.tsx +15 -5
  36. package/src/ai/chat-task-contracts.ts +4 -1
  37. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  38. package/src/ai/chat-task-reasons.ts +1 -1
  39. package/src/ai/chat-tasks.ts +55 -25
  40. package/src/ai/client/controller.ts +59 -0
  41. package/src/ai/default-tools.ts +9 -1
  42. package/src/ai/executor.ts +198 -39
  43. package/src/ai/firecrawl-tools.ts +15 -2
  44. package/src/ai/index.ts +3 -0
  45. package/src/ai/migrate.ts +26 -2
  46. package/src/ai/open-questions.ts +172 -0
  47. package/src/ai/projects-routes.ts +3 -0
  48. package/src/ai/protocol.ts +14 -2
  49. package/src/ai/question-text.ts +8 -0
  50. package/src/ai/recent-resources.ts +240 -0
  51. package/src/ai/recent-sent-refs.ts +38 -0
  52. package/src/ai/routes.ts +195 -5
  53. package/src/ai/runtime.ts +1 -0
  54. package/src/ai/skills-routes.ts +3 -0
  55. package/src/ai/skills.ts +10 -1
  56. package/src/ai/store.ts +245 -24
  57. package/src/ai/testing.ts +36 -0
  58. package/src/ai/tool-audit.ts +8 -0
  59. package/src/ai/tool-sentences.ts +152 -34
  60. package/src/ai/tool-wording.ts +36 -8
  61. package/src/ai/tools.ts +8 -3
  62. package/src/ai/types.ts +43 -4
  63. package/src/api/accounts-entities.ts +2 -1
  64. package/src/api/admin-ai-projects.ts +2 -0
  65. package/src/api/admin-ai-skills.ts +2 -0
  66. package/src/api/admin-ai-usage.ts +2 -0
  67. package/src/api/admin-geoip.ts +80 -0
  68. package/src/api/admin-identity.ts +3 -0
  69. package/src/api/admin-lifecycle.ts +3 -0
  70. package/src/api/admin-outgoing-mail.ts +2 -0
  71. package/src/api/app-approval-actions.ts +31 -7
  72. package/src/api/app-approval.ts +46 -5
  73. package/src/api/approvals-loader.ts +23 -1
  74. package/src/api/approvals.ts +2 -0
  75. package/src/api/auth/schemas.ts +3 -0
  76. package/src/api/auth.ts +67 -12
  77. package/src/api/capabilities.ts +44 -11
  78. package/src/api/capability-streams.ts +12 -0
  79. package/src/api/help.ts +4 -28
  80. package/src/api/index.ts +6 -1
  81. package/src/api/lasting-access-messages.ts +17 -0
  82. package/src/api/mcp.ts +30 -3
  83. package/src/api/me-panel-consent.ts +71 -0
  84. package/src/api/me.ts +159 -36
  85. package/src/api/search.ts +3 -1
  86. package/src/api/sync-ops.ts +2 -0
  87. package/src/api/widgets.ts +3 -0
  88. package/src/approvals/Approvals.tsx +166 -262
  89. package/src/approvals/approval-card.ts +68 -0
  90. package/src/approvals/messages.ts +12 -24
  91. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  92. package/src/browser/app-approval.ts +54 -5
  93. package/src/browser/commands.ts +22 -6
  94. package/src/browser/composer-catalog.ts +45 -3
  95. package/src/browser/composer-insert.ts +10 -0
  96. package/src/browser/composer-open-messages.ts +17 -0
  97. package/src/browser/composer-open.ts +110 -0
  98. package/src/browser/discoverable-command.ts +6 -0
  99. package/src/browser/hints.ts +6 -0
  100. package/src/browser/live-connection.ts +26 -2
  101. package/src/browser/navigation-apps.ts +7 -0
  102. package/src/browser/panel-context-bridge.ts +30 -0
  103. package/src/browser/panel-context-receiver.ts +53 -0
  104. package/src/browser/panel-menu.tsx +56 -0
  105. package/src/browser/panel-messages.ts +11 -0
  106. package/src/browser/panel-shortcuts.ts +31 -0
  107. package/src/browser/panel-slot.ts +412 -10
  108. package/src/browser/panel.ts +37 -0
  109. package/src/browser/search-commands.ts +6 -3
  110. package/src/capabilities/client.ts +8 -2
  111. package/src/capabilities/reviews.ts +205 -0
  112. package/src/capabilities/server.ts +10 -3
  113. package/src/capabilities/testing.ts +47 -0
  114. package/src/capabilities/types.ts +2 -2
  115. package/src/cli/account.ts +13 -3
  116. package/src/cli/admin/geoip.ts +115 -0
  117. package/src/cli/admin/index.ts +3 -0
  118. package/src/contracts/app-approval.ts +123 -16
  119. package/src/contracts/approvals.ts +2 -2
  120. package/src/contracts/capabilities.ts +410 -18
  121. package/src/contracts/geoip.ts +42 -0
  122. package/src/contracts/index.ts +3 -0
  123. package/src/contracts/panel-context-plain.ts +99 -0
  124. package/src/contracts/panel-context.ts +93 -0
  125. package/src/contracts/seen-hints.ts +35 -0
  126. package/src/contracts/shared.ts +3 -0
  127. package/src/server/client-ip.ts +5 -0
  128. package/src/server/help-payload.ts +38 -0
  129. package/src/server/middleware/auth.ts +13 -1
  130. package/src/server/middleware/middleware.ts +9 -0
  131. package/src/server/middleware/no-transform.ts +19 -0
  132. package/src/server/middleware/rate-limit.ts +4 -6
  133. package/src/server/request-context/address.ts +45 -0
  134. package/src/server/request-context/geoip.ts +380 -0
  135. package/src/server/request-context/index.ts +33 -0
  136. package/src/server/request-context/user-agent.ts +95 -0
  137. package/src/services/accounts/app.ts +23 -3
  138. package/src/services/accounts/users.ts +12 -1
  139. package/src/services/app-approval.ts +382 -59
  140. package/src/services/approvals/device.ts +49 -31
  141. package/src/services/approvals/index.ts +7 -5
  142. package/src/services/approvals/list.ts +27 -6
  143. package/src/services/approvals/question.ts +68 -0
  144. package/src/services/audit/index.ts +1 -0
  145. package/src/services/auth-flows/ipa.ts +39 -2
  146. package/src/services/auth-flows/notification-sender.ts +3 -0
  147. package/src/services/auth-flows/password-reset.ts +32 -6
  148. package/src/services/geoip/index.ts +164 -0
  149. package/src/services/geoip/store.ts +30 -0
  150. package/src/services/geoip/upload.ts +150 -0
  151. package/src/services/identity/routes.ts +5 -1
  152. package/src/services/index.ts +13 -0
  153. package/src/services/known-browsers.ts +312 -0
  154. package/src/services/login-address-store.ts +51 -0
  155. package/src/services/login-context.ts +118 -0
  156. package/src/services/mandates/index.ts +3 -0
  157. package/src/services/mandates/policy.ts +15 -3
  158. package/src/services/notifications/email-frame.ts +1 -1
  159. package/src/services/oauth-tokens.ts +16 -2
  160. package/src/services/panel-context-consent.ts +58 -0
  161. package/src/services/pdf/markdown.ts +160 -2
  162. package/src/services/service-account-credentials.ts +21 -0
  163. package/src/services/session/index.ts +296 -41
  164. package/src/services/session/recent.ts +13 -0
  165. package/src/services/webauthn.ts +12 -0
  166. package/src/shared/email-html.ts +2 -0
  167. package/src/shared/help.ts +3 -0
  168. package/src/shared-text/cursor.ts +22 -0
  169. package/src/shared-text/documents.ts +142 -0
  170. package/src/shared-text/events.ts +43 -0
  171. package/src/shared-text/index.ts +12 -0
  172. package/src/shared-text/log.ts +48 -0
  173. package/src/shared-text/text-diff.ts +23 -0
  174. package/src/shared-text/types.ts +41 -0
  175. package/src/ssr/AdminLayout.tsx +2 -0
  176. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  177. package/src/ssr/CloudShell.island.tsx +10 -2
  178. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  179. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  180. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  181. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  182. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  183. package/src/ssr/HelpPanelView.island.tsx +27 -0
  184. package/src/ssr/Layout.tsx +25 -2
  185. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  186. package/src/ssr/LayoutHelp.tsx +222 -419
  187. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
  188. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  189. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  190. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  191. package/src/ssr/MobileNavigation.tsx +2 -2
  192. package/src/ssr/PanelLayout.tsx +9 -1
  193. package/src/ssr/PanelRuntime.island.tsx +7 -0
  194. package/src/ssr/PanelSlot.tsx +53 -3
  195. package/src/ssr/ShellRuntime.island.tsx +11 -2
  196. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  197. package/src/ssr/app-color.ts +14 -9
  198. package/src/ssr/app-navigation.ts +16 -3
  199. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  200. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  201. package/src/ssr/help-messages.ts +4 -0
  202. package/src/ssr/help.ts +21 -0
  203. package/src/ssr/hints.ts +25 -0
  204. package/src/ssr/index.ts +3 -0
  205. package/src/ssr/layout-context.ts +2 -0
  206. package/src/ssr/layout-help-article.server.ts +50 -0
  207. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  208. package/src/ssr/layout-help-panel-document.ts +67 -0
  209. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  210. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  211. package/src/ssr/layout-help-panel-modules.ts +7 -0
  212. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  213. package/src/ssr/layout-help-panel-search.tsx +69 -0
  214. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  215. package/src/ssr/layout-help-panel-state.ts +141 -0
  216. package/src/ssr/layout-help-panel-types.ts +19 -0
  217. package/src/ssr/layout-help-start.ts +33 -7
  218. package/src/ssr/layout-help-views.tsx +484 -0
  219. package/src/ssr/panel-consent.ts +29 -0
  220. package/src/ssr/panel-context.ts +10 -0
  221. package/src/ssr/panel-idle.ts +9 -0
  222. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  223. package/src/ssr/platform-messages.ts +18 -0
  224. package/src/ssr/shell-data.tsx +2 -1
  225. package/src/styles/approvals.css +6 -0
  226. package/src/styles/effects.css +213 -9
  227. package/src/styles/file-chooser.css +2 -1
  228. package/src/styles/global.css +49 -12
  229. package/src/styles/panel.css +47 -4
  230. package/src/styles/resource-search.css +6 -2
  231. package/src/styles/tokens.css +17 -11
  232. package/src/styles/utilities-buttons.css +3 -2
  233. package/src/styles/utilities-data.css +2 -1
  234. package/src/styles/utilities-feedback.css +2 -2
  235. package/src/styles/utilities-layout.css +3 -13
  236. package/src/styles/utilities-markdown-editor.css +6 -6
  237. package/src/styles/utilities-navigation.css +17 -22
@@ -1,4 +1,5 @@
1
1
  import { type SQL, sql } from "bun";
2
+ import type { z } from "zod";
2
3
  import { lazySync } from "../_internal/process-sync";
3
4
  import { env } from "../config/env";
4
5
  import { type AccountCategory, accountCategory } from "../contracts/account-categories";
@@ -9,20 +10,30 @@ import {
9
10
  APP_APPROVAL_PROTOCOL,
10
11
  type AppActionSummary,
11
12
  type AppActionView,
13
+ type AppApprovalAccept,
14
+ type AppDeviceChange,
15
+ type AppDeviceChangeStatus,
12
16
  type AppDevicePublicKey,
13
17
  AppDevicePublicKeySchema,
14
18
  type AppDeviceRequest,
19
+ type AppDeviceRevokeResultSchema,
15
20
  type AppDeviceView,
21
+ AppLoginContextSchema,
22
+ type AppPairingConfirmResultSchema,
16
23
  type AppPendingLogin,
17
24
  appDeviceProofMessage,
18
25
  appPairingProofMessage,
19
26
  APP_APPROVAL_LIMITS as limits,
20
27
  } from "../contracts/app-approval";
28
+ import { addressPrefix } from "../server/request-context/address";
21
29
  import { publicCloudOrigin } from "../shared/app-url";
22
30
  import { isAccountCategoryAllowed } from "./account-category-policy";
23
31
  import { appApprovalText } from "./approvals/device";
24
32
  import { audit } from "./audit";
33
+ import { countryHistory, findBrowser, trustState } from "./known-browsers";
25
34
  import { logger } from "./logging";
35
+ import { type AppLoginAddressStore, appLoginAddressMatches, appLoginAddressProof, valkeyAppLoginAddressStore } from "./login-address-store";
36
+ import { type AppLoginRequestContext, buildLoginContext, trimAppLoginResponse } from "./login-context";
26
37
  import { pairingSecret } from "./pairing-secret";
27
38
  import { toPgTextArray } from "./postgres";
28
39
  import { requireEligibleAccount, requireRecentWebSession } from "./session/recent";
@@ -130,7 +141,30 @@ type PairingRow = {
130
141
  device_id: string | null;
131
142
  comparison: string | null;
132
143
  };
144
+ type ChangeRow = {
145
+ id: string;
146
+ issuer: string;
147
+ user_id: string;
148
+ kind: "pair" | "revoke";
149
+ pairing_id: string | null;
150
+ device_id: string | null;
151
+ end_sessions: boolean;
152
+ requested_by_sid: string;
153
+ auth_epoch: string;
154
+ state: "pending" | "approved" | "denied" | "cancelled";
155
+ created_at: Date;
156
+ expires_at: Date;
157
+ };
158
+ const changeStatus = (row: ChangeRow): AppDeviceChangeStatus => ({
159
+ state: row.state === "pending" && new Date(row.expires_at).getTime() <= Date.now() ? "expired" : row.state,
160
+ expiresAt: iso(row.expires_at),
161
+ });
162
+ const changeName = (name: string) =>
163
+ Array.from(name.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ""))
164
+ .slice(0, 80)
165
+ .join("");
133
166
  type LoginRow = {
167
+ context: unknown;
134
168
  kind: "browser" | "terminal";
135
169
  device_authorization_id: string | null;
136
170
  client_id: string | null;
@@ -189,7 +223,7 @@ export type AppTerminalLogins = {
189
223
  input: {
190
224
  deviceAuthorizationId: string;
191
225
  clientId: string;
192
- user: Pick<AccountRow, "id" | "uid" | "provider" | "profile">;
226
+ user: Pick<AccountRow, "id" | "uid" | "provider" | "profile" | "auth_epoch">;
193
227
  decision: "approve" | "deny";
194
228
  },
195
229
  ): Promise<boolean>;
@@ -262,7 +296,25 @@ export const createAppApprovalService = (
262
296
  configuration = (requireEnabled = true) => readAppApprovalConfig(db, requireEnabled),
263
297
  hints: AppLoginDecisionHints = syncLoginDecisionHints,
264
298
  push: AppLoginPushSender = fetchPushSender,
299
+ addresses: AppLoginAddressStore = valkeyAppLoginAddressStore,
265
300
  ) => {
301
+ // Error messages may contain their inputs; log only the error class, never addresses or proofs.
302
+ const addressFailure = (error: unknown) =>
303
+ log.warn("App login address hint unavailable", { error: error instanceof Error ? error.name : "UnknownError" });
304
+ const addSameNetwork = async (logins: AppPendingLogin[], address?: string) => {
305
+ const prefix = addressPrefix(address ?? "unknown");
306
+ const browserLogins = logins.filter((login) => !login.terminal && login.context);
307
+ if (!prefix || !browserLogins.length) return;
308
+ try {
309
+ const proofs = await addresses.read(browserLogins.map((login) => login.requestId));
310
+ for (const login of browserLogins) {
311
+ const proof = proofs.get(login.requestId);
312
+ if (proof && login.context && appLoginAddressMatches(proof, prefix)) login.context.sameNetwork = true;
313
+ }
314
+ } catch (error) {
315
+ addressFailure(error);
316
+ }
317
+ };
266
318
  const config = async (requireEnabled = true) => {
267
319
  const value = await configuration(requireEnabled);
268
320
  if (requireEnabled && (!value.enabled || !value.appOrigin)) return reject("UNAVAILABLE", 503);
@@ -276,12 +328,14 @@ export const createAppApprovalService = (
276
328
  const cleanup = async () => {
277
329
  // Bounded opportunistic maintenance: each accepted write retires up to 100
278
330
  // expired rows per table, so bursts cannot trigger an unbounded sweep.
279
- await db`DELETE FROM auth.app_pairings WHERE id IN (SELECT id FROM auth.app_pairings WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
331
+ 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)`;
332
+ await db`DELETE FROM auth.app_pairings WHERE id IN (SELECT p.id FROM auth.app_pairings p
333
+ 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)
334
+ ORDER BY p.expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
280
335
  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)`;
281
336
  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)`;
282
337
  };
283
- const authorizePairing = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
284
- await fresh(tx, actor);
338
+ const pairingOwner = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
285
339
  if (
286
340
  pairing.initiated_by !== actor.userId ||
287
341
  pairing.initiator_sid !== actor.sid ||
@@ -290,6 +344,10 @@ export const createAppApprovalService = (
290
344
  return reject("FORBIDDEN", 403);
291
345
  return eligible(tx, pairing.user_id);
292
346
  };
347
+ const authorizePairing = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
348
+ await fresh(tx, actor);
349
+ return pairingOwner(tx, actor, pairing, cfg);
350
+ };
293
351
  const activeDevice = async (tx: SQL, id: string, issuer: string): Promise<DeviceRow> => {
294
352
  const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices WHERE id = ${id}::uuid AND issuer = ${issuer} FOR UPDATE`;
295
353
  if (!device || device.revoked_at) return reject("FORBIDDEN", 403);
@@ -297,17 +355,65 @@ export const createAppApprovalService = (
297
355
  return device;
298
356
  };
299
357
 
358
+ const enrollPairing = async (tx: SQL, p: PairingRow, issuer: string) => {
359
+ if (!p.device_id || !p.name) return reject("CONFLICT", 409);
360
+ const [count] = await tx<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.app_devices
361
+ WHERE issuer=${issuer} AND user_id=${p.user_id}::uuid AND revoked_at IS NULL`;
362
+ if ((count?.count ?? 0) >= limits.devicesPerAccount) return reject("LIMIT_REACHED", 429);
363
+ const key = AppDevicePublicKeySchema.parse(p.public_key);
364
+ const existing =
365
+ await tx`SELECT id FROM auth.app_devices WHERE issuer=${issuer} AND user_id=${p.user_id}::uuid AND public_key=${JSON.stringify(key)}::text::jsonb`;
366
+ if (existing.length) return reject("CONFLICT", 409);
367
+ await tx`INSERT INTO auth.app_devices(id,issuer,user_id,name,public_key,assisted,enrolled_by)
368
+ VALUES (${p.device_id}::uuid,${issuer},${p.user_id}::uuid,${p.name},${JSON.stringify(key)}::text::jsonb,${p.assisted},${p.initiated_by}::uuid)`;
369
+ await tx`UPDATE auth.app_pairings SET state='confirmed' WHERE id=${p.id}::uuid`;
370
+ await record(tx, "device.enroll", p.initiated_by, p.device_id, { targetUserId: p.user_id, assisted: p.assisted });
371
+ };
372
+ const createChange = async (
373
+ tx: SQL,
374
+ cfg: AppApprovalConfig,
375
+ actor: AppApprovalActor,
376
+ epoch: string,
377
+ kind: ChangeRow["kind"],
378
+ targetId: string,
379
+ pairingId: string | null,
380
+ endSessions: boolean,
381
+ ) => {
382
+ const [count] = await tx<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.app_device_changes
383
+ WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND state='pending' AND expires_at>now()`;
384
+ if ((count?.count ?? 0) >= limits.pendingPerAccount) return reject("LIMIT_REACHED", 429);
385
+ const id = crypto.randomUUID(),
386
+ expiresAt = future(limits.loginSeconds);
387
+ const [row] = await tx<ChangeRow[]>`INSERT INTO auth.app_device_changes
388
+ (id,issuer,user_id,kind,pairing_id,device_id,end_sessions,requested_by_sid,auth_epoch,expires_at)
389
+ VALUES (${id}::uuid,${cfg.issuer},${actor.userId}::uuid,${kind},${pairingId}::uuid,
390
+ ${kind === "revoke" ? targetId : null}::uuid,${endSessions},${actor.sid}::uuid,${epoch},${expiresAt}) RETURNING *`;
391
+ // Audit metadata redacts keys that name sessions, so the choice is recorded as `endSignIns`.
392
+ await record(tx, "device_change.request", actor.userId, targetId, { changeId: id, kind, endSignIns: endSessions });
393
+ return row!;
394
+ };
395
+ const ownedChange = async (tx: SQL, cfg: AppApprovalConfig, actor: AppApprovalActor, id: string) => {
396
+ const [row] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes
397
+ WHERE id=${id}::uuid AND issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND requested_by_sid=${actor.sid}::uuid`;
398
+ if (!row) return reject("UNAVAILABLE", 404);
399
+ return row;
400
+ };
401
+
300
402
  /** Runs after the login commit and never delays or fails it. The body names only
301
403
  * this Cloud and the request id; a 410 means the phone's subscription is gone. */
302
- const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string, options?: { terminal: boolean }) => {
404
+ const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string, options?: { accept: AppApprovalAccept }) => {
405
+ if (!cfg.enabled || !cfg.appOrigin) return;
303
406
  try {
407
+ // A withdrawn device change keeps its window, so withdrawing and asking again cannot push without bound.
304
408
  const rows = await db<{ push_token: string }[]>`
305
409
  UPDATE auth.app_devices d SET login_wake_at=now(), login_wake_ref=${requestId}::uuid
306
410
  WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL
307
- AND (${options?.terminal ?? false}=false OR 'terminal-logins'=ANY(d.accepts))
411
+ AND (${options?.accept ?? null}::text IS NULL OR ${options?.accept ?? null}::text=ANY(d.accepts))
308
412
  AND (d.login_wake_at IS NULL OR d.login_wake_at <= now() - ${WAKE_SECONDS} * interval '1 second'
309
- OR NOT EXISTS (SELECT 1 FROM auth.app_logins w
310
- WHERE w.id=d.login_wake_ref AND w.state='pending' AND w.expires_at > now()))
413
+ OR (NOT EXISTS (SELECT 1 FROM auth.app_logins w
414
+ WHERE w.id=d.login_wake_ref AND w.state='pending' AND w.expires_at > now())
415
+ AND NOT EXISTS (SELECT 1 FROM auth.app_device_changes w
416
+ WHERE w.id=d.login_wake_ref AND w.state IN ('pending','cancelled') AND w.expires_at > now())))
311
417
  RETURNING push_token`;
312
418
  await Promise.all(
313
419
  Array.from(new Set(rows.map((row) => row.push_token)), async (token) => {
@@ -448,7 +554,9 @@ export const createAppApprovalService = (
448
554
  pairingResult: async (id: string, token: string, key: AppDevicePublicKey) => {
449
555
  const cfg = await config();
450
556
  const [p] = await db<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
451
- if (!p || !matches(token, p.secret_hash) || new Date(p.expires_at).getTime() <= Date.now()) return reject("FORBIDDEN", 403);
557
+ if (!p || !matches(token, p.secret_hash)) return reject("FORBIDDEN", 403);
558
+ const age = Date.now() - new Date(p.expires_at).getTime();
559
+ if (age >= 0 && !(age < 60_000 && (p.state === "confirmed" || p.state === "cancelled"))) return reject("FORBIDDEN", 403);
452
560
  if (p.public_key) {
453
561
  const claimed = AppDevicePublicKeySchema.parse(p.public_key);
454
562
  if (claimed.x !== key.x || claimed.y !== key.y) return reject("FORBIDDEN", 403);
@@ -459,16 +567,33 @@ export const createAppApprovalService = (
459
567
  const cfg = await config();
460
568
  return db.begin(async (tx) => {
461
569
  const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
462
- if (!p || new Date(p.expires_at).getTime() <= Date.now()) return reject("UNAVAILABLE", 404);
570
+ if (!p) return reject("UNAVAILABLE", 404);
571
+ const [change] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes WHERE pairing_id=${p.id}::uuid`;
572
+ const age = Date.now() - new Date(p.expires_at).getTime();
573
+ if (age >= 0 && !(age < 60_000 && (p.state === "confirmed" || change !== undefined))) return reject("UNAVAILABLE", 404);
463
574
  // A pairing from before the account was signed out everywhere reads as expired.
464
- if ((await authorizePairing(tx, actor, p, cfg)).auth_epoch !== p.auth_epoch) return reject("UNAVAILABLE", 404);
465
- return { state: p.state, name: p.name, comparison: p.comparison, deviceId: p.device_id, userId: p.user_id };
575
+ const user = await (change ? pairingOwner(tx, actor, p, cfg) : authorizePairing(tx, actor, p, cfg));
576
+ if (user.auth_epoch !== p.auth_epoch) return reject("UNAVAILABLE", 404);
577
+ return {
578
+ state: p.state,
579
+ name: p.name,
580
+ comparison: p.comparison,
581
+ deviceId: p.device_id,
582
+ userId: p.user_id,
583
+ approval: change ? changeStatus(change) : null,
584
+ };
466
585
  });
467
586
  },
468
- confirmPairing: async (actor: AppApprovalActor, id: string, code: string) => {
587
+ confirmPairing: async (actor: AppApprovalActor, id: string, code: string): Promise<z.infer<typeof AppPairingConfirmResultSchema>> => {
469
588
  const cfg = await config();
470
- return db.begin(async (tx) => {
471
- const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer} FOR UPDATE`;
589
+ let wakeId: string | undefined;
590
+ const result = await db.begin(async (tx) => {
591
+ const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
592
+ if (!snapshot) return reject("CONFLICT", 409);
593
+ await tx`SELECT id FROM auth.users WHERE id=${snapshot.user_id}::uuid FOR NO KEY UPDATE`;
594
+ await tx`SELECT id FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${snapshot.user_id}::uuid AND revoked_at IS NULL ORDER BY id FOR UPDATE`;
595
+ const [change] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes WHERE pairing_id=${id}::uuid FOR UPDATE`;
596
+ const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
472
597
  if (
473
598
  !p ||
474
599
  p.state !== "claimed" ||
@@ -478,36 +603,66 @@ export const createAppApprovalService = (
478
603
  !p.name
479
604
  )
480
605
  return reject("CONFLICT", 409);
481
- // Lock the account before checking the session and epoch: a concurrent sign-out
482
- // everywhere either revokes this device after it exists, or fails these checks.
483
- // The epoch also voids assisted pairings, whose administrator session outlives it.
484
- await tx`SELECT id FROM auth.users WHERE id = ${p.user_id}::uuid FOR UPDATE`;
485
- if ((await authorizePairing(tx, actor, p, cfg)).auth_epoch !== p.auth_epoch) return reject("CONFLICT", 409);
486
- const [count] = await tx<
487
- { count: number }[]
488
- >`SELECT count(*)::int AS count FROM auth.app_devices WHERE issuer = ${cfg.issuer} AND user_id = ${p.user_id}::uuid AND revoked_at IS NULL`;
489
- if ((count?.count ?? 0) >= limits.devicesPerAccount) return reject("LIMIT_REACHED", 429);
490
- const key = AppDevicePublicKeySchema.parse(p.public_key);
491
- const existing =
492
- await tx`SELECT id FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${p.user_id}::uuid AND public_key=${JSON.stringify(key)}::text::jsonb`;
493
- if (existing.length) return reject("CONFLICT", 409);
494
- await tx`INSERT INTO auth.app_devices(id,issuer,user_id,name,public_key,assisted,enrolled_by) VALUES (${p.device_id}::uuid,${cfg.issuer},${p.user_id}::uuid,${p.name},${JSON.stringify(key)}::text::jsonb,${p.assisted},${actor.userId}::uuid)`;
495
- await tx`UPDATE auth.app_pairings SET state = 'confirmed' WHERE id = ${id}::uuid`;
496
- await record(tx, "device.enroll", actor.userId, p.device_id, { targetUserId: p.user_id, assisted: p.assisted });
497
- return { deviceId: p.device_id };
606
+ const user = await authorizePairing(tx, actor, p, cfg);
607
+ if (user.auth_epoch !== p.auth_epoch) return reject("CONFLICT", 409);
608
+ if (change) {
609
+ if (changeStatus(change).state !== "pending") return reject("CONFLICT", 409);
610
+ return { deviceId: p.device_id, approval: changeStatus(change) };
611
+ }
612
+ const [active] =
613
+ await tx`SELECT id FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${p.user_id}::uuid AND revoked_at IS NULL LIMIT 1`;
614
+ if (!p.assisted && active) {
615
+ const requested = await createChange(tx, cfg, actor, user.auth_epoch, "pair", p.device_id, p.id, false);
616
+ await tx`UPDATE auth.app_pairings SET expires_at=GREATEST(expires_at,${requested.expires_at}) WHERE id=${p.id}::uuid`;
617
+ wakeId = requested.id;
618
+ return { deviceId: p.device_id, approval: changeStatus(requested) };
619
+ }
620
+ await enrollPairing(tx, p, cfg.issuer);
621
+ return { deviceId: p.device_id, approval: null };
498
622
  });
623
+ if (wakeId) void wake(cfg, actor.userId, wakeId, { accept: "device-changes" });
624
+ return result;
499
625
  },
500
626
  cancelPairing: async (actor: AppApprovalActor, id: string) => {
501
627
  const cfg = await config();
502
628
  return db.begin(async (tx) => {
503
- const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer} FOR UPDATE`;
629
+ const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
630
+ if (!snapshot) return reject("UNAVAILABLE", 404);
631
+ await tx`SELECT id FROM auth.users WHERE id=${snapshot.user_id}::uuid FOR NO KEY UPDATE`;
632
+ const [change] = await tx`SELECT id FROM auth.app_device_changes WHERE pairing_id=${id}::uuid FOR UPDATE`;
633
+ const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
504
634
  if (!p) return reject("UNAVAILABLE", 404);
505
- await authorizePairing(tx, actor, p, cfg);
635
+ if (change) await pairingOwner(tx, actor, p, cfg);
636
+ else await authorizePairing(tx, actor, p, cfg);
506
637
  if (p.state === "confirmed") return reject("CONFLICT", 409);
638
+ await tx`UPDATE auth.app_device_changes SET state='cancelled' WHERE pairing_id=${id}::uuid AND state='pending'`;
507
639
  await tx`UPDATE auth.app_pairings SET state='cancelled' WHERE id=${id}::uuid`;
508
640
  await record(tx, "pairing.cancel", actor.userId, id);
509
641
  });
510
642
  },
643
+ /** This session's newest removal of a device, so a browser that lost the answer can follow or withdraw it. */
644
+ findRemoval: async (actor: AppApprovalActor, lookup: { changeId: string } | { deviceId: string }) => {
645
+ const cfg = await config(false);
646
+ const changeId = "changeId" in lookup ? lookup.changeId : null,
647
+ deviceId = "deviceId" in lookup ? lookup.deviceId : null;
648
+ const [row] = await db<ChangeRow[]>`SELECT * FROM auth.app_device_changes
649
+ WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND requested_by_sid=${actor.sid}::uuid AND kind='revoke'
650
+ AND (${changeId}::uuid IS NULL OR id=${changeId}::uuid) AND (${deviceId}::uuid IS NULL OR device_id=${deviceId}::uuid)
651
+ ORDER BY created_at DESC, id DESC LIMIT 1`;
652
+ if (!row) return reject("UNAVAILABLE", 404);
653
+ return { changeId: row.id, endSessions: row.end_sessions, ...changeStatus(row) };
654
+ },
655
+ cancelChange: async (actor: AppApprovalActor, id: string) => {
656
+ const cfg = await config(false);
657
+ return db.begin(async (tx) => {
658
+ await tx`SELECT id FROM auth.users WHERE id=${actor.userId}::uuid FOR NO KEY UPDATE`;
659
+ const change = await ownedChange(tx, cfg, actor, id);
660
+ if (changeStatus(change).state !== "pending") return reject("CONFLICT", 409);
661
+ await tx`UPDATE auth.app_device_changes SET state='cancelled' WHERE id=${id}::uuid`;
662
+ if (change.pairing_id)
663
+ await tx`UPDATE auth.app_pairings SET state='cancelled' WHERE id=${change.pairing_id}::uuid AND state='claimed'`;
664
+ });
665
+ },
511
666
  listDevices: async (actor: AppApprovalActor, after?: string) => {
512
667
  const cfg = await config(false);
513
668
  const rows = await db<
@@ -518,18 +673,40 @@ export const createAppApprovalService = (
518
673
  nextCursor: rows.length > limits.pageSize ? rows[limits.pageSize - 1]!.id : null,
519
674
  };
520
675
  },
521
- mutateDevice: async (actor: AppApprovalActor, id: string, name?: string) => {
676
+ mutateDevice: async (
677
+ actor: AppApprovalActor,
678
+ id: string,
679
+ name?: string,
680
+ endSessions = true,
681
+ ): Promise<z.infer<typeof AppDeviceRevokeResultSchema> | undefined> => {
522
682
  const cfg = await config(false);
523
- return db.begin(async (tx) => {
524
- await fresh(tx, actor);
525
- const [device] = await tx<
526
- DeviceRow[]
527
- >`SELECT * FROM auth.app_devices WHERE id=${id}::uuid AND issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid FOR UPDATE`;
683
+ let wakeId: string | undefined;
684
+ const result = await db.begin(async (tx) => {
685
+ await tx`SELECT id FROM auth.users WHERE id=${actor.userId}::uuid FOR NO KEY UPDATE`;
686
+ const user = await fresh(tx, actor);
687
+ const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices
688
+ WHERE id=${id}::uuid AND issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid FOR UPDATE`;
528
689
  if (!device) return reject("UNAVAILABLE", 404);
529
- if (name !== undefined) await tx`UPDATE auth.app_devices SET name=${name} WHERE id=${id}::uuid`;
530
- else await tx`UPDATE auth.app_devices SET revoked_at=COALESCE(revoked_at,now()) WHERE id=${id}::uuid`;
531
- await record(tx, name === undefined ? "device.revoke" : "device.rename", actor.userId, id);
690
+ if (name !== undefined) {
691
+ await tx`UPDATE auth.app_devices SET name=${name} WHERE id=${id}::uuid`;
692
+ await record(tx, "device.rename", actor.userId, id);
693
+ return;
694
+ }
695
+ if (device.revoked_at) return { state: "revoked" as const };
696
+ // Only a configured Cloud can ask its devices; without one, an administrator revokes.
697
+ if (!cfg.enabled || !cfg.appOrigin) return reject("UNAVAILABLE", 503);
698
+ // A retry after a lost answer gets the same request; a changed session choice replaces it.
699
+ const [open] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes
700
+ WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND kind='revoke' AND device_id=${id}::uuid
701
+ AND requested_by_sid=${actor.sid}::uuid AND state='pending' AND expires_at>now() FOR UPDATE`;
702
+ if (open?.end_sessions === endSessions) return { state: "pending" as const, changeId: open.id, expiresAt: iso(open.expires_at) };
703
+ if (open) await tx`UPDATE auth.app_device_changes SET state='cancelled' WHERE id=${open.id}::uuid`;
704
+ const requested = await createChange(tx, cfg, actor, user.auth_epoch, "revoke", id, null, endSessions);
705
+ wakeId = requested.id;
706
+ return { state: "pending" as const, changeId: requested.id, expiresAt: iso(requested.expires_at) };
532
707
  });
708
+ if (wakeId) void wake(cfg, actor.userId, wakeId, { accept: "device-changes" });
709
+ return result;
533
710
  },
534
711
  /** Active devices of any account, for administrators. Bounded by the active-device limit. */
535
712
  listUserDevices: async (actor: AppDeviceAdministrator, userId: string): Promise<AppDeviceView[]> => {
@@ -556,7 +733,7 @@ export const createAppApprovalService = (
556
733
  return { device: view(row!), revoked: true };
557
734
  });
558
735
  },
559
- startLogin: async (identifier: string, category: AccountCategory) => {
736
+ startLogin: async (identifier: string, category: AccountCategory, request?: AppLoginRequestContext) => {
560
737
  const cfg = await config();
561
738
  await cleanup();
562
739
  const id = crypto.randomUUID(),
@@ -568,12 +745,25 @@ export const createAppApprovalService = (
568
745
  await db.begin(async (tx) => {
569
746
  let candidate = await loginCandidate(tx, identifier, category);
570
747
  if (candidate && !(await loginBudget(tx, cfg.issuer, candidate.id))) candidate = undefined;
571
- // Unknown, ambiguous, disabled and throttled accounts have the identical
572
- // public shape and a decoy pending transaction; no account enumeration.
573
- await tx`INSERT INTO auth.app_logins(id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at)
574
- VALUES (${id}::uuid,${cfg.issuer},${candidate?.id ?? null}::uuid,${candidate?.auth_epoch ?? null},${category},${hash(browserSecret)},${challenge},${code},${expiresAt})`;
748
+ const now = new Date();
749
+ // These reads also run for decoys; the helpers use random account IDs when absent.
750
+ const browser = await findBrowser(tx, { userId: candidate?.id, cookie: request?.cookie ?? null, now });
751
+ const trust = await trustState(tx, now);
752
+ const countries = await countryHistory(tx, { userId: candidate?.id, country: request?.country ?? null, now });
753
+ const context = buildLoginContext({ request, browser, trust, countries, now });
754
+ // Unknown, ambiguous, disabled and throttled accounts keep the identical public shape.
755
+ await tx`INSERT INTO auth.app_logins(id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at,context)
756
+ VALUES (${id}::uuid,${cfg.issuer},${candidate?.id ?? null}::uuid,${candidate?.auth_epoch ?? null},${category},${hash(browserSecret)},${challenge},${code},${expiresAt},${candidate && context ? JSON.stringify(context) : null}::text::jsonb)`;
575
757
  owner = candidate?.id;
576
758
  });
759
+ // No cache I/O in SQL transactions; decoys do the same address work as real requests.
760
+ if (request?.prefix) {
761
+ try {
762
+ await addresses.remember(id, appLoginAddressProof(request.prefix), expiresAt);
763
+ } catch (error) {
764
+ addressFailure(error);
765
+ }
766
+ }
577
767
  // Not awaited: decoy and real requests answer alike, and push is only a wake-up.
578
768
  if (owner) void wake(cfg, owner, id);
579
769
  return { requestId: id, browserSecret, comparison: code, expiresAt: iso(expiresAt), pollAfterSeconds: limits.pollSeconds };
@@ -606,10 +796,15 @@ export const createAppApprovalService = (
606
796
  if (!inserted.length) return reject("CONFLICT", 409);
607
797
  owner = candidate?.id;
608
798
  });
609
- if (owner) void wake(cfg, owner, id, { terminal: true });
799
+ if (owner) void wake(cfg, owner, id, { accept: "terminal-logins" });
610
800
  return { comparison: code, expiresAt: iso(expiresAt) };
611
801
  },
612
- deviceCommand: async (request: AppDeviceRequest, actions: AppDeviceActions, terminal?: AppTerminalLogins) => {
802
+ deviceCommand: async (
803
+ request: AppDeviceRequest,
804
+ actions: AppDeviceActions,
805
+ terminal?: AppTerminalLogins,
806
+ options?: { address?: string },
807
+ ) => {
613
808
  const cfg = await config();
614
809
  const { proof, signature } = request;
615
810
  const now = Math.floor(Date.now() / 1000);
@@ -623,8 +818,19 @@ export const createAppApprovalService = (
623
818
  )
624
819
  return reject("FORBIDDEN", 403);
625
820
  await cleanup();
821
+ let decidedBrowserId: string | undefined;
626
822
  const outcome = await db.begin(async (tx) => {
823
+ let changeUserId: string | undefined;
824
+ if (proof.command.operation === "change") {
825
+ const [snapshot] = await tx<
826
+ Pick<DeviceRow, "user_id">[]
827
+ >`SELECT user_id FROM auth.app_devices WHERE id=${proof.deviceId}::uuid AND issuer=${cfg.issuer}`;
828
+ if (!snapshot) return reject("FORBIDDEN", 403);
829
+ changeUserId = snapshot.user_id;
830
+ await tx`SELECT id FROM auth.users WHERE id=${changeUserId}::uuid FOR NO KEY UPDATE`;
831
+ }
627
832
  const device = await activeDevice(tx, proof.deviceId, cfg.issuer);
833
+ if (changeUserId && device.user_id !== changeUserId) return reject("CONFLICT", 409);
628
834
  if (!(await verify(AppDevicePublicKeySchema.parse(device.public_key), appDeviceProofMessage(proof), signature)))
629
835
  return reject("FORBIDDEN", 403);
630
836
  const used =
@@ -632,6 +838,77 @@ export const createAppApprovalService = (
632
838
  if (!used.length) return reject("CONFLICT", 409);
633
839
  await tx`UPDATE auth.app_devices SET last_used_at=now() WHERE id=${device.id}::uuid`;
634
840
  const command = proof.command;
841
+ if (command.operation === "change") {
842
+ // Like terminal sign-ins, only a device whose latest inbox opted in may decide device changes.
843
+ if (!device.accepts?.includes("device-changes")) return reject("CONFLICT", 409);
844
+ let target: DeviceRow | undefined;
845
+ if (command.kind === "revoke") {
846
+ [target] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices
847
+ WHERE id=${command.targetId}::uuid AND issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid FOR UPDATE`;
848
+ }
849
+ const [change] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes
850
+ WHERE id=${command.changeId}::uuid AND issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid FOR UPDATE`;
851
+ const user = await eligible(tx, device.user_id);
852
+ if (
853
+ !change ||
854
+ changeStatus(change).state !== "pending" ||
855
+ change.kind !== command.kind ||
856
+ change.end_sessions !== command.endSessions ||
857
+ change.auth_epoch !== user.auth_epoch
858
+ )
859
+ return reject("CONFLICT", 409);
860
+ let pairing: PairingRow | undefined;
861
+ if (change.kind === "pair") {
862
+ [pairing] = await tx<
863
+ PairingRow[]
864
+ >`SELECT * FROM auth.app_pairings WHERE id=${change.pairing_id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
865
+ if (
866
+ !pairing ||
867
+ pairing.user_id !== user.id ||
868
+ pairing.state !== "claimed" ||
869
+ new Date(pairing.expires_at).getTime() <= Date.now() ||
870
+ pairing.auth_epoch !== user.auth_epoch ||
871
+ pairing.device_id !== command.targetId
872
+ )
873
+ return reject("CONFLICT", 409);
874
+ const [initiator] = await tx`SELECT sid FROM auth.session_families WHERE sid=${pairing.initiator_sid}::uuid
875
+ AND user_id=${pairing.initiated_by}::uuid AND auth_epoch=${user.auth_epoch} AND revoked_at IS NULL AND expires_at>now() FOR UPDATE`;
876
+ if (!initiator) return reject("CONFLICT", 409);
877
+ } else {
878
+ if (!target || change.device_id !== command.targetId) return reject("CONFLICT", 409);
879
+ if (command.decision === "deny" && device.id === change.device_id) return reject("CONFLICT", 409);
880
+ const [requester] = await tx`SELECT sid FROM auth.session_families WHERE sid=${change.requested_by_sid}::uuid
881
+ AND user_id=${change.user_id}::uuid AND auth_epoch=${user.auth_epoch} AND revoked_at IS NULL AND expires_at>now() FOR UPDATE`;
882
+ if (!requester) return reject("CONFLICT", 409);
883
+ }
884
+ const state = command.decision === "approve" ? ("approved" as const) : ("denied" as const);
885
+ // Locks above may outlive the request. Expiry wins before applying either decision.
886
+ const decided = await tx`UPDATE auth.app_device_changes SET state=${state},decided_by=${device.id}::uuid
887
+ WHERE id=${change.id}::uuid AND state='pending' AND expires_at > clock_timestamp() RETURNING id`;
888
+ if (!decided.length) return reject("CONFLICT", 409);
889
+ let ended = 0;
890
+ if (state === "denied") {
891
+ if (pairing) await tx`UPDATE auth.app_pairings SET state='cancelled' WHERE id=${pairing.id}::uuid`;
892
+ } else if (pairing) {
893
+ await enrollPairing(tx, pairing, cfg.issuer);
894
+ } else {
895
+ await tx`UPDATE auth.app_devices SET revoked_at=COALESCE(revoked_at,now()) WHERE id=${command.targetId}::uuid`;
896
+ if (change.end_sessions) {
897
+ const families = await tx`UPDATE auth.session_families SET revoked_at=now(), revocation_reason='app_device_removed'
898
+ WHERE user_id=${user.id}::uuid AND app_device_id=${command.targetId}::uuid AND sid<>${change.requested_by_sid}::uuid
899
+ AND revoked_at IS NULL AND expires_at>now() AND auth_epoch=${user.auth_epoch} RETURNING sid`;
900
+ ended = families.length;
901
+ }
902
+ await record(tx, "device.revoke", user.id, command.targetId, { changeId: change.id });
903
+ }
904
+ await record(tx, `device_change.${command.decision}`, user.id, command.targetId, {
905
+ changeId: change.id,
906
+ kind: change.kind,
907
+ endSignIns: change.end_sessions,
908
+ ended,
909
+ });
910
+ return { state };
911
+ }
635
912
  if (command.operation === "push") {
636
913
  await tx`UPDATE auth.app_devices SET push_token=${command.token} WHERE id=${device.id}::uuid`;
637
914
  return { state: "updated" as const };
@@ -686,11 +963,42 @@ export const createAppApprovalService = (
686
963
  lifetimeSeconds: row.lifetime_seconds,
687
964
  };
688
965
  }
966
+ if (row.kind === "browser" && command.accept?.includes("login-context")) {
967
+ const parsed = AppLoginContextSchema.safeParse(row.context);
968
+ if (parsed.success) {
969
+ // sameNetwork is transient, even if an unexpected stored value contains it.
970
+ const { sameNetwork: _transient, ...context } = parsed.data;
971
+ login.context = context;
972
+ }
973
+ }
689
974
  requests.push(login);
690
975
  }
691
976
  if (command.operation === "inbox") {
692
977
  await tx`UPDATE auth.app_devices SET actions_at=now(), accepts=${toPgTextArray(command.accept ?? [])}::text[] WHERE id=${device.id}::uuid`;
693
- return { operation: "inbox" as const, userId: device.user_id, logins: requests };
978
+ let changes: AppDeviceChange[] | undefined;
979
+ if (command.accept?.includes("device-changes")) {
980
+ const rows = await tx<
981
+ (ChangeRow & { target_id: string; name: string })[]
982
+ >`SELECT c.*, COALESCE(p.device_id,d.id) AS target_id, COALESCE(p.name,d.name) AS name
983
+ FROM auth.app_device_changes c LEFT JOIN auth.app_pairings p ON p.id=c.pairing_id LEFT JOIN auth.app_devices d ON d.id=c.device_id
984
+ WHERE c.issuer=${cfg.issuer} AND c.user_id=${device.user_id}::uuid AND c.state='pending' AND c.expires_at>now() AND c.auth_epoch=${user.auth_epoch}
985
+ AND ((c.kind='revoke' AND EXISTS (SELECT 1 FROM auth.session_families f
986
+ WHERE f.sid=c.requested_by_sid AND f.user_id=c.user_id
987
+ AND f.revoked_at IS NULL AND f.expires_at>now() AND f.auth_epoch=${user.auth_epoch}))
988
+ OR (p.state='claimed' AND p.expires_at>now() AND p.auth_epoch=${user.auth_epoch}
989
+ AND EXISTS (SELECT 1 FROM auth.session_families f WHERE f.sid=p.initiator_sid AND f.user_id=p.initiated_by
990
+ AND f.revoked_at IS NULL AND f.expires_at>now() AND f.auth_epoch=${user.auth_epoch})))
991
+ ORDER BY c.created_at,c.id LIMIT ${limits.pendingPerAccount}`;
992
+ changes = rows.map((row) => ({
993
+ changeId: row.id,
994
+ kind: row.kind,
995
+ device: { id: row.target_id, name: changeName(row.name) },
996
+ endSessions: row.end_sessions,
997
+ createdAt: iso(row.created_at),
998
+ expiresAt: iso(row.expires_at),
999
+ }));
1000
+ }
1001
+ return { operation: "inbox" as const, userId: device.user_id, logins: requests, changes };
694
1002
  }
695
1003
  return { requests, pollAfterSeconds: limits.pollSeconds };
696
1004
  }
@@ -727,23 +1035,27 @@ export const createAppApprovalService = (
727
1035
  const decided = await tx`UPDATE auth.app_logins SET state=${state},approved_by=${device.id}::uuid
728
1036
  WHERE id=${row.id}::uuid AND expires_at > clock_timestamp() RETURNING id`;
729
1037
  if (!decided.length) return reject("CONFLICT", 409);
1038
+ const storedContext = row.kind === "browser" ? AppLoginContextSchema.safeParse(row.context) : null;
730
1039
  await record(tx, `login.${command.decision}`, device.user_id, device.id, {
731
1040
  requestId: row.id,
732
1041
  ...(row.kind === "terminal" ? { kind: "terminal", clientId: row.client_id, deviceGrantId: row.device_authorization_id } : {}),
1042
+ ...(storedContext?.success ? { level: storedContext.data.level } : {}),
733
1043
  });
1044
+ if (row.kind === "browser") decidedBrowserId = row.id;
734
1045
  return { state };
735
1046
  });
736
1047
  if (outcome.operation !== undefined) {
737
1048
  const command = proof.command;
738
1049
  const input = { userId: outcome.userId, issuer: cfg.issuer };
739
1050
  if (outcome.operation === "inbox") {
740
- const inbox = { logins: outcome.logins, ...(await actions.inbox(input)), pollAfterSeconds: limits.pollSeconds };
741
- // Multibyte summaries share the unchanged response budget with pending sign-ins.
742
- while (inbox.actions.length && new TextEncoder().encode(JSON.stringify(inbox)).length > limits.bodyBytes) {
743
- inbox.actions.pop();
744
- inbox.moreActions++;
745
- }
746
- return inbox;
1051
+ const inbox = {
1052
+ logins: outcome.logins,
1053
+ ...(outcome.changes === undefined ? {} : { changes: outcome.changes, moreChanges: 0 }),
1054
+ ...(await actions.inbox(input)),
1055
+ pollAfterSeconds: limits.pollSeconds,
1056
+ };
1057
+ await addSameNetwork(inbox.logins, options?.address);
1058
+ return trimAppLoginResponse(inbox);
747
1059
  }
748
1060
  if (command.operation === "action") {
749
1061
  const view = await actions.view({ ...input, actionId: command.actionId });
@@ -763,8 +1075,19 @@ export const createAppApprovalService = (
763
1075
  }
764
1076
  return reject("INVALID_REQUEST", 400);
765
1077
  }
1078
+ if (outcome.requests !== undefined) {
1079
+ await addSameNetwork(outcome.requests, options?.address);
1080
+ return trimAppLoginResponse(outcome);
1081
+ }
766
1082
  // Only after commit: the waiting browser re-reads Postgres, so a hint never grants anything.
767
1083
  if (proof.command.operation === "decide") hints.publish(proof.command.requestId);
1084
+ if (decidedBrowserId) {
1085
+ try {
1086
+ await addresses.forget(decidedBrowserId);
1087
+ } catch (error) {
1088
+ addressFailure(error);
1089
+ }
1090
+ }
768
1091
  return outcome;
769
1092
  },
770
1093
  /** With `hold`, a pending request is answered on its decision, its expiry, or after `hold.ms`, whichever comes first. */
@@ -825,7 +1148,7 @@ export const createAppApprovalService = (
825
1148
  await record(tx, "login.consume", user.id, device.id, { requestId: id });
826
1149
  // This commit is the authorization boundary. A later issuance/network
827
1150
  // failure requires a new login, never replay of the consumed approval.
828
- return user.id;
1151
+ return { userId: user.id, authEpoch: Number(row.auth_epoch), deviceId: device.id, deviceName: device.name };
829
1152
  });
830
1153
  },
831
1154
  };