@k2b/cloud 0.38.0 → 0.40.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 (244) hide show
  1. package/README.md +8 -0
  2. package/package.json +12 -5
  3. package/scripts/build-canvas-workers.ts +109 -29
  4. package/scripts/build.ts +3 -5
  5. package/src/_internal/app-favicon.ts +4 -2
  6. package/src/_internal/app-path.ts +25 -0
  7. package/src/_internal/app-presentation.ts +4 -0
  8. package/src/_internal/canvas-worker.ts +5 -3
  9. package/src/_internal/capabilities.ts +72 -48
  10. package/src/_internal/capability-reviews.ts +81 -0
  11. package/src/_internal/capability-schemas.ts +29 -0
  12. package/src/_internal/capability-sentences.ts +216 -1
  13. package/src/_internal/capability-streams.ts +7 -0
  14. package/src/_internal/define-app.ts +63 -16
  15. package/src/_internal/font-preloads.ts +2 -1
  16. package/src/_internal/panel-cookie.ts +39 -0
  17. package/src/_internal/registry-validation.ts +21 -26
  18. package/src/_internal/runtime-context.ts +1 -0
  19. package/src/_internal/runtime-watcher.ts +6 -0
  20. package/src/_internal/static-assets.ts +52 -3
  21. package/src/_internal/status-preserving-ssr.ts +2 -0
  22. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  23. package/src/access/PermissionEditor.tsx +32 -7
  24. package/src/access/ResourceApiKeys.tsx +5 -1
  25. package/src/account/EntitySearch.tsx +1 -1
  26. package/src/ai/action-review.ts +101 -0
  27. package/src/ai/approval-routes.ts +5 -2
  28. package/src/ai/approvals.ts +139 -28
  29. package/src/ai/background-review.ts +72 -0
  30. package/src/ai/browser.ts +10 -0
  31. package/src/ai/capabilities.ts +29 -11
  32. package/src/ai/capability-execution.ts +76 -43
  33. package/src/ai/capability-result.ts +95 -0
  34. package/src/ai/chat/action-card.ts +398 -0
  35. package/src/ai/chat/action-messages.ts +129 -0
  36. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  37. package/src/ai/chat/action-question.fixtures.ts +733 -0
  38. package/src/ai/chat/action-question.tsx +669 -0
  39. package/src/ai/chat/action-sentences.ts +1 -44
  40. package/src/ai/chat/blocks.tsx +19 -25
  41. package/src/ai/chat/message-actions.tsx +6 -0
  42. package/src/ai/chat/tool-action.tsx +481 -0
  43. package/src/ai/chat/turn-view.tsx +45 -163
  44. package/src/ai/chat/view-block.tsx +22 -7
  45. package/src/ai/chat-task-contracts.ts +31 -3
  46. package/src/ai/chat-task-reasons-migrate.ts +75 -0
  47. package/src/ai/chat-task-reasons.ts +92 -0
  48. package/src/ai/chat-tasks.ts +438 -78
  49. package/src/ai/client/controller.ts +59 -0
  50. package/src/ai/default-tools.ts +9 -1
  51. package/src/ai/executor.ts +190 -39
  52. package/src/ai/index.ts +14 -0
  53. package/src/ai/migrate.ts +39 -4
  54. package/src/ai/projects-routes.ts +3 -0
  55. package/src/ai/protocol.ts +14 -2
  56. package/src/ai/recent-resources.ts +240 -0
  57. package/src/ai/recent-sent-refs.ts +38 -0
  58. package/src/ai/request-context.ts +7 -1
  59. package/src/ai/routes.ts +172 -2
  60. package/src/ai/runtime.ts +13 -27
  61. package/src/ai/skills-routes.ts +3 -0
  62. package/src/ai/skills.ts +10 -1
  63. package/src/ai/store.ts +232 -27
  64. package/src/ai/timeline.ts +9 -1
  65. package/src/ai/tool-audit.ts +8 -0
  66. package/src/ai/tool-wording.ts +67 -0
  67. package/src/ai/turn-abort.ts +33 -0
  68. package/src/ai/types.ts +40 -4
  69. package/src/api/accounts-entities.ts +2 -1
  70. package/src/api/admin-ai-projects.ts +2 -0
  71. package/src/api/admin-ai-skills.ts +2 -0
  72. package/src/api/admin-ai-usage.ts +2 -0
  73. package/src/api/admin-core-settings.ts +190 -139
  74. package/src/api/admin-geoip.ts +80 -0
  75. package/src/api/admin-identity.ts +3 -0
  76. package/src/api/admin-lifecycle.ts +3 -0
  77. package/src/api/admin-outgoing-mail.ts +6 -2
  78. package/src/api/app-approval.ts +54 -6
  79. package/src/api/approvals-loader.ts +23 -1
  80. package/src/api/approvals.ts +3 -1
  81. package/src/api/auth.ts +25 -6
  82. package/src/api/capabilities.ts +44 -11
  83. package/src/api/capability-streams.ts +12 -0
  84. package/src/api/help.ts +4 -28
  85. package/src/api/index.ts +7 -1
  86. package/src/api/lasting-access-messages.ts +17 -0
  87. package/src/api/mcp.ts +3 -0
  88. package/src/api/me-panel-consent.ts +71 -0
  89. package/src/api/me.ts +80 -33
  90. package/src/api/search.ts +3 -1
  91. package/src/api/sync-ops.ts +2 -0
  92. package/src/api/widgets.ts +3 -0
  93. package/src/approvals/Approvals.tsx +180 -238
  94. package/src/approvals/messages.ts +12 -24
  95. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  96. package/src/browser/CloudResourceSearch.tsx +10 -6
  97. package/src/browser/app-approval.ts +60 -6
  98. package/src/browser/commands.ts +22 -6
  99. package/src/browser/composer-catalog.ts +145 -0
  100. package/src/browser/composer-insert-messages.ts +31 -0
  101. package/src/browser/composer-insert.ts +160 -0
  102. package/src/browser/composer-open-messages.ts +17 -0
  103. package/src/browser/composer-open.ts +110 -0
  104. package/src/browser/discoverable-command.ts +6 -0
  105. package/src/browser/hints.ts +6 -0
  106. package/src/browser/live-connection.ts +26 -2
  107. package/src/browser/navigation-apps.ts +7 -0
  108. package/src/browser/panel-context-bridge.ts +30 -0
  109. package/src/browser/panel-context-receiver.ts +53 -0
  110. package/src/browser/panel-menu.tsx +56 -0
  111. package/src/browser/panel-messages.ts +35 -0
  112. package/src/browser/panel-shortcuts.ts +31 -0
  113. package/src/browser/panel-slot.ts +756 -0
  114. package/src/browser/panel.ts +65 -0
  115. package/src/browser/resource-picker.tsx +12 -1
  116. package/src/browser/search-commands.ts +6 -3
  117. package/src/capabilities/client.ts +8 -2
  118. package/src/capabilities/reviews.ts +205 -0
  119. package/src/capabilities/server.ts +10 -3
  120. package/src/capabilities/testing.ts +47 -0
  121. package/src/capabilities/types.ts +2 -2
  122. package/src/cli/account.ts +13 -3
  123. package/src/cli/admin/geoip.ts +115 -0
  124. package/src/cli/admin/index.ts +3 -0
  125. package/src/contracts/app-approval.ts +159 -12
  126. package/src/contracts/app.ts +14 -0
  127. package/src/contracts/approvals.ts +2 -2
  128. package/src/contracts/capabilities.ts +410 -18
  129. package/src/contracts/geoip.ts +42 -0
  130. package/src/contracts/index.ts +3 -0
  131. package/src/contracts/panel-context-plain.ts +99 -0
  132. package/src/contracts/panel-context.ts +93 -0
  133. package/src/contracts/registry.ts +3 -1
  134. package/src/contracts/seen-hints.ts +35 -0
  135. package/src/contracts/shared.ts +2 -0
  136. package/src/server/client-ip.ts +5 -0
  137. package/src/server/help-payload.ts +38 -0
  138. package/src/server/middleware/auth.ts +11 -1
  139. package/src/server/middleware/middleware.ts +9 -0
  140. package/src/server/middleware/no-transform.ts +19 -0
  141. package/src/server/middleware/rate-limit.ts +4 -6
  142. package/src/server/request-context/address.ts +45 -0
  143. package/src/server/request-context/geoip.ts +380 -0
  144. package/src/server/request-context/index.ts +33 -0
  145. package/src/server/request-context/user-agent.ts +95 -0
  146. package/src/server/services/access.ts +5 -1
  147. package/src/services/accounts/posix.ts +2 -1
  148. package/src/services/app-approval.ts +533 -90
  149. package/src/services/approvals/device.ts +9 -5
  150. package/src/services/approvals/index.ts +6 -5
  151. package/src/services/approvals/list.ts +26 -5
  152. package/src/services/auth-flows/notification-sender.ts +3 -0
  153. package/src/services/geoip/index.ts +164 -0
  154. package/src/services/geoip/store.ts +30 -0
  155. package/src/services/geoip/upload.ts +150 -0
  156. package/src/services/identity/routes.ts +5 -1
  157. package/src/services/index.ts +32 -3
  158. package/src/services/known-browsers.ts +312 -0
  159. package/src/services/login-address-store.ts +51 -0
  160. package/src/services/login-context.ts +118 -0
  161. package/src/services/mandates/index.ts +3 -0
  162. package/src/services/mandates/policy.ts +15 -3
  163. package/src/services/notifications/browser.ts +16 -5
  164. package/src/services/notifications/email-frame.ts +1 -1
  165. package/src/services/oauth-tokens.ts +16 -2
  166. package/src/services/panel-context-consent.ts +58 -0
  167. package/src/services/pdf/image-protocol.ts +135 -0
  168. package/src/services/pdf/image-variants.ts +124 -0
  169. package/src/services/pdf/image-worker.ts +31 -0
  170. package/src/services/pdf/image.ts +109 -0
  171. package/src/services/pdf/index.ts +9 -0
  172. package/src/services/service-account-credentials.ts +21 -0
  173. package/src/services/session/index.ts +97 -16
  174. package/src/services/session/recent.ts +13 -0
  175. package/src/services/settings/api.ts +11 -6
  176. package/src/services/settings/app.ts +5 -4
  177. package/src/services/settings/core-settings.ts +4 -3
  178. package/src/services/settings/index.ts +19 -17
  179. package/src/services/settings/namespace.ts +2 -1
  180. package/src/services/settings/store.ts +83 -11
  181. package/src/services/webauthn.ts +12 -0
  182. package/src/shared/ai-platform-prompt.ts +1 -1
  183. package/src/shared/app-presentation.ts +1 -0
  184. package/src/shared/email-html.ts +2 -0
  185. package/src/shared/help.ts +3 -0
  186. package/src/shared/redirect.ts +12 -0
  187. package/src/shared-text/cursor.ts +22 -0
  188. package/src/shared-text/documents.ts +142 -0
  189. package/src/shared-text/events.ts +43 -0
  190. package/src/shared-text/index.ts +12 -0
  191. package/src/shared-text/log.ts +48 -0
  192. package/src/shared-text/text-diff.ts +23 -0
  193. package/src/shared-text/types.ts +41 -0
  194. package/src/ssr/AdminLayout.tsx +2 -0
  195. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  196. package/src/ssr/CloudShell.island.tsx +95 -10
  197. package/src/ssr/Layout.tsx +66 -10
  198. package/src/ssr/LayoutHeader.tsx +15 -9
  199. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  200. package/src/ssr/LayoutHelp.tsx +435 -274
  201. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  202. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  203. package/src/ssr/LayoutRail.tsx +2 -2
  204. package/src/ssr/LayoutTabBar.tsx +29 -0
  205. package/src/ssr/MobileNavigation.tsx +2 -2
  206. package/src/ssr/PanelLayout.tsx +35 -0
  207. package/src/ssr/PanelRuntime.island.tsx +94 -0
  208. package/src/ssr/PanelSlot.tsx +158 -0
  209. package/src/ssr/PanelTrigger.tsx +49 -0
  210. package/src/ssr/ShellRuntime.island.tsx +69 -5
  211. package/src/ssr/app-appearance.ts +3 -2
  212. package/src/ssr/app-color.ts +105 -0
  213. package/src/ssr/app-launchpad.tsx +18 -1
  214. package/src/ssr/app-navigation.ts +28 -0
  215. package/src/ssr/compact-shell.baseline.json +5270 -0
  216. package/src/ssr/compact-shell.browser-harness.ts +439 -0
  217. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  218. package/src/ssr/help-messages.ts +4 -0
  219. package/src/ssr/help.ts +20 -0
  220. package/src/ssr/hints.ts +25 -0
  221. package/src/ssr/index.ts +5 -0
  222. package/src/ssr/islands/index.ts +4 -3
  223. package/src/ssr/layout-context.ts +2 -0
  224. package/src/ssr/layout-help-article.server.ts +50 -0
  225. package/src/ssr/layout-help-start.ts +33 -7
  226. package/src/ssr/panel-consent.ts +29 -0
  227. package/src/ssr/panel-context.ts +10 -0
  228. package/src/ssr/panel-slot.browser-harness.tsx +159 -0
  229. package/src/ssr/platform-messages.ts +48 -0
  230. package/src/ssr/rail-navigation.ts +44 -0
  231. package/src/ssr/shell-data.tsx +5 -0
  232. package/src/ssr/shell-shortcuts.ts +13 -2
  233. package/src/styles/effects.css +201 -11
  234. package/src/styles/file-chooser.css +2 -1
  235. package/src/styles/global.css +314 -63
  236. package/src/styles/panel.css +416 -0
  237. package/src/styles/resource-search.css +6 -2
  238. package/src/styles/tokens.css +36 -13
  239. package/src/styles/utilities-buttons.css +3 -2
  240. package/src/styles/utilities-data.css +2 -1
  241. package/src/styles/utilities-feedback.css +2 -2
  242. package/src/styles/utilities-layout.css +3 -13
  243. package/src/styles/utilities-markdown-editor.css +6 -6
  244. package/src/styles/utilities-navigation.css +22 -43
@@ -1,27 +1,41 @@
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";
5
6
  import {
7
+ APP_ACTION_LIMITS,
6
8
  APP_APPROVAL_FEATURES,
7
9
  APP_APPROVAL_PATH,
8
10
  APP_APPROVAL_PROTOCOL,
9
11
  type AppActionSummary,
10
12
  type AppActionView,
13
+ type AppApprovalAccept,
14
+ type AppDeviceChange,
15
+ type AppDeviceChangeStatus,
11
16
  type AppDevicePublicKey,
12
17
  AppDevicePublicKeySchema,
13
18
  type AppDeviceRequest,
19
+ type AppDeviceRevokeResultSchema,
14
20
  type AppDeviceView,
21
+ AppLoginContextSchema,
22
+ type AppPairingConfirmResultSchema,
15
23
  type AppPendingLogin,
16
24
  appDeviceProofMessage,
17
25
  appPairingProofMessage,
18
26
  APP_APPROVAL_LIMITS as limits,
19
27
  } from "../contracts/app-approval";
28
+ import { addressPrefix } from "../server/request-context/address";
20
29
  import { publicCloudOrigin } from "../shared/app-url";
21
30
  import { isAccountCategoryAllowed } from "./account-category-policy";
31
+ import { appApprovalText } from "./approvals/device";
22
32
  import { audit } from "./audit";
33
+ import { countryHistory, findBrowser, trustState } from "./known-browsers";
23
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";
24
37
  import { pairingSecret } from "./pairing-secret";
38
+ import { toPgTextArray } from "./postgres";
25
39
  import { requireEligibleAccount, requireRecentWebSession } from "./session/recent";
26
40
  import { CORE_SETTINGS } from "./settings/core-settings";
27
41
  import { decryptValue } from "./settings/crypto";
@@ -109,6 +123,7 @@ type DeviceRow = {
109
123
  created_at: Date;
110
124
  last_used_at: Date | null;
111
125
  assisted: boolean;
126
+ accepts: string[] | null;
112
127
  };
113
128
  type PairingRow = {
114
129
  id: string;
@@ -126,7 +141,35 @@ type PairingRow = {
126
141
  device_id: string | null;
127
142
  comparison: string | null;
128
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("");
129
166
  type LoginRow = {
167
+ context: unknown;
168
+ kind: "browser" | "terminal";
169
+ device_authorization_id: string | null;
170
+ client_id: string | null;
171
+ scopes: string[] | null;
172
+ lifetime_seconds: number | null;
130
173
  id: string;
131
174
  user_id: string | null;
132
175
  auth_epoch: string | null;
@@ -164,9 +207,30 @@ export type AppDeviceActions = {
164
207
  approved: boolean;
165
208
  }): Promise<boolean>;
166
209
  };
167
- // Bundle Action wake-ups for 30 seconds while the previously woken request is still open.
210
+ export type AppTerminalLoginInput = {
211
+ deviceAuthorizationId: string;
212
+ identifier: string;
213
+ clientId: string;
214
+ scopes: string[];
215
+ lifetimeSeconds: number;
216
+ };
217
+ export type AppTerminalLogins = {
218
+ /** Live pending authorizations whose registry client still allows the device grant. */
219
+ describe(tx: SQL, ids: string[]): Promise<Map<string, { id: string; name: string }>>;
220
+ /** In the sign-in decision transaction; false aborts both decisions and their audits. */
221
+ decide(
222
+ tx: SQL,
223
+ input: {
224
+ deviceAuthorizationId: string;
225
+ clientId: string;
226
+ user: Pick<AccountRow, "id" | "uid" | "provider" | "profile" | "auth_epoch">;
227
+ decision: "approve" | "deny";
228
+ },
229
+ ): Promise<boolean>;
230
+ };
231
+ // Bundle sign-in and Action wake-ups for 30 seconds while the previously woken request is still open.
168
232
  // Cloud Login gives Actions a separate 30-per-token budget in five minutes, preserving the full sign-in budget.
169
- const ACTION_WAKE_SECONDS = 30;
233
+ const WAKE_SECONDS = 30;
170
234
  /** Best-effort sign-in wake-ups through the trusted authenticator; returns the HTTP status. */
171
235
  export type AppLoginPushSender = (
172
236
  appOrigin: string,
@@ -232,7 +296,25 @@ export const createAppApprovalService = (
232
296
  configuration = (requireEnabled = true) => readAppApprovalConfig(db, requireEnabled),
233
297
  hints: AppLoginDecisionHints = syncLoginDecisionHints,
234
298
  push: AppLoginPushSender = fetchPushSender,
299
+ addresses: AppLoginAddressStore = valkeyAppLoginAddressStore,
235
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
+ };
236
318
  const config = async (requireEnabled = true) => {
237
319
  const value = await configuration(requireEnabled);
238
320
  if (requireEnabled && (!value.enabled || !value.appOrigin)) return reject("UNAVAILABLE", 503);
@@ -246,12 +328,14 @@ export const createAppApprovalService = (
246
328
  const cleanup = async () => {
247
329
  // Bounded opportunistic maintenance: each accepted write retires up to 100
248
330
  // expired rows per table, so bursts cannot trigger an unbounded sweep.
249
- 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)`;
250
- await db`DELETE FROM auth.app_logins WHERE id IN (SELECT id FROM auth.app_logins 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)`;
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)`;
251
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)`;
252
337
  };
253
- const authorizePairing = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
254
- await fresh(tx, actor);
338
+ const pairingOwner = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
255
339
  if (
256
340
  pairing.initiated_by !== actor.userId ||
257
341
  pairing.initiator_sid !== actor.sid ||
@@ -260,6 +344,10 @@ export const createAppApprovalService = (
260
344
  return reject("FORBIDDEN", 403);
261
345
  return eligible(tx, pairing.user_id);
262
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
+ };
263
351
  const activeDevice = async (tx: SQL, id: string, issuer: string): Promise<DeviceRow> => {
264
352
  const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices WHERE id = ${id}::uuid AND issuer = ${issuer} FOR UPDATE`;
265
353
  if (!device || device.revoked_at) return reject("FORBIDDEN", 403);
@@ -267,14 +355,68 @@ export const createAppApprovalService = (
267
355
  return device;
268
356
  };
269
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
+
270
402
  /** Runs after the login commit and never delays or fails it. The body names only
271
403
  * this Cloud and the request id; a 410 means the phone's subscription is gone. */
272
- const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string) => {
404
+ const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string, options?: { accept: AppApprovalAccept }) => {
405
+ if (!cfg.enabled || !cfg.appOrigin) return;
273
406
  try {
274
- const rows = await db<{ push_token: string }[]>`SELECT DISTINCT push_token FROM auth.app_devices
275
- WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL LIMIT ${limits.devicesPerAccount}`;
407
+ // A withdrawn device change keeps its window, so withdrawing and asking again cannot push without bound.
408
+ const rows = await db<{ push_token: string }[]>`
409
+ UPDATE auth.app_devices d SET login_wake_at=now(), login_wake_ref=${requestId}::uuid
410
+ WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL
411
+ AND (${options?.accept ?? null}::text IS NULL OR ${options?.accept ?? null}::text=ANY(d.accepts))
412
+ AND (d.login_wake_at IS NULL OR d.login_wake_at <= now() - ${WAKE_SECONDS} * interval '1 second'
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())))
417
+ RETURNING push_token`;
276
418
  await Promise.all(
277
- rows.map(async ({ push_token: token }) => {
419
+ Array.from(new Set(rows.map((row) => row.push_token)), async (token) => {
278
420
  const status = await push(cfg.appOrigin, { token, cloudOrigin: cfg.issuer, requestRef: requestId });
279
421
  if (status === 410)
280
422
  await db`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
@@ -285,6 +427,31 @@ export const createAppApprovalService = (
285
427
  }
286
428
  };
287
429
 
430
+ const loginCandidate = async (tx: SQL, identifier: string, category?: AccountCategory) => {
431
+ const candidates = await tx<AccountRow[]>`
432
+ SELECT id,uid,provider,profile,auth_epoch,account_expires FROM auth.users
433
+ WHERE lower(uid)=lower(${identifier}) OR lower(btrim(mail))=lower(${identifier}) LIMIT 2`;
434
+ const candidate = candidates.length === 1 ? candidates[0] : undefined;
435
+ if (
436
+ candidate &&
437
+ ((category !== undefined && accountCategory(candidate) !== category) ||
438
+ (candidate.account_expires && new Date(candidate.account_expires).getTime() <= Date.now()) ||
439
+ !(await isAccountCategoryAllowed(candidate, tx)))
440
+ )
441
+ return undefined;
442
+ if (!candidate) return undefined;
443
+ // Re-read under the account lock: starts share one budget, and epoch changes cannot race the snapshot.
444
+ const [locked] = await tx<AccountRow[]>`SELECT id,uid,provider,profile,auth_epoch,account_expires
445
+ FROM auth.users WHERE id=${candidate.id}::uuid FOR UPDATE`;
446
+ if (locked?.account_expires && new Date(locked.account_expires).getTime() <= Date.now()) return undefined;
447
+ return locked;
448
+ };
449
+ const loginBudget = async (tx: SQL, issuer: string, userId: string) => {
450
+ const [count] = await tx<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.app_logins
451
+ WHERE issuer=${issuer} AND user_id=${userId}::uuid AND expires_at > now() AND state IN ('pending','approved')`;
452
+ return (count?.count ?? 0) < limits.pendingPerAccount;
453
+ };
454
+
288
455
  return {
289
456
  config,
290
457
  cleanup,
@@ -303,7 +470,7 @@ export const createAppApprovalService = (
303
470
  AND device.issuer=${cfg.issuer} AND device.revoked_at IS NULL AND device.push_token IS NOT NULL
304
471
  AND device.actions_at IS NOT NULL
305
472
  LIMIT ${limits.devicesPerAccount}
306
- ) AND (d.action_wake_at IS NULL OR d.action_wake_at <= now() - ${ACTION_WAKE_SECONDS} * interval '1 second'
473
+ ) AND (d.action_wake_at IS NULL OR d.action_wake_at <= now() - ${WAKE_SECONDS} * interval '1 second'
307
474
  OR NOT EXISTS (SELECT 1 FROM approvals.requests w
308
475
  WHERE w.id=d.action_wake_ref AND w.status='pending' AND w.expires_at > now()))
309
476
  RETURNING d.push_token,d.user_id`;
@@ -387,7 +554,9 @@ export const createAppApprovalService = (
387
554
  pairingResult: async (id: string, token: string, key: AppDevicePublicKey) => {
388
555
  const cfg = await config();
389
556
  const [p] = await db<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
390
- 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);
391
560
  if (p.public_key) {
392
561
  const claimed = AppDevicePublicKeySchema.parse(p.public_key);
393
562
  if (claimed.x !== key.x || claimed.y !== key.y) return reject("FORBIDDEN", 403);
@@ -398,16 +567,33 @@ export const createAppApprovalService = (
398
567
  const cfg = await config();
399
568
  return db.begin(async (tx) => {
400
569
  const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
401
- 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);
402
574
  // A pairing from before the account was signed out everywhere reads as expired.
403
- if ((await authorizePairing(tx, actor, p, cfg)).auth_epoch !== p.auth_epoch) return reject("UNAVAILABLE", 404);
404
- 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
+ };
405
585
  });
406
586
  },
407
- confirmPairing: async (actor: AppApprovalActor, id: string, code: string) => {
587
+ confirmPairing: async (actor: AppApprovalActor, id: string, code: string): Promise<z.infer<typeof AppPairingConfirmResultSchema>> => {
408
588
  const cfg = await config();
409
- return db.begin(async (tx) => {
410
- 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`;
411
597
  if (
412
598
  !p ||
413
599
  p.state !== "claimed" ||
@@ -417,36 +603,66 @@ export const createAppApprovalService = (
417
603
  !p.name
418
604
  )
419
605
  return reject("CONFLICT", 409);
420
- // Lock the account before checking the session and epoch: a concurrent sign-out
421
- // everywhere either revokes this device after it exists, or fails these checks.
422
- // The epoch also voids assisted pairings, whose administrator session outlives it.
423
- await tx`SELECT id FROM auth.users WHERE id = ${p.user_id}::uuid FOR UPDATE`;
424
- if ((await authorizePairing(tx, actor, p, cfg)).auth_epoch !== p.auth_epoch) return reject("CONFLICT", 409);
425
- const [count] = await tx<
426
- { count: number }[]
427
- >`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`;
428
- if ((count?.count ?? 0) >= limits.devicesPerAccount) return reject("LIMIT_REACHED", 429);
429
- const key = AppDevicePublicKeySchema.parse(p.public_key);
430
- const existing =
431
- 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`;
432
- if (existing.length) return reject("CONFLICT", 409);
433
- 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)`;
434
- await tx`UPDATE auth.app_pairings SET state = 'confirmed' WHERE id = ${id}::uuid`;
435
- await record(tx, "device.enroll", actor.userId, p.device_id, { targetUserId: p.user_id, assisted: p.assisted });
436
- 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 };
437
622
  });
623
+ if (wakeId) void wake(cfg, actor.userId, wakeId, { accept: "device-changes" });
624
+ return result;
438
625
  },
439
626
  cancelPairing: async (actor: AppApprovalActor, id: string) => {
440
627
  const cfg = await config();
441
628
  return db.begin(async (tx) => {
442
- 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`;
443
634
  if (!p) return reject("UNAVAILABLE", 404);
444
- await authorizePairing(tx, actor, p, cfg);
635
+ if (change) await pairingOwner(tx, actor, p, cfg);
636
+ else await authorizePairing(tx, actor, p, cfg);
445
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'`;
446
639
  await tx`UPDATE auth.app_pairings SET state='cancelled' WHERE id=${id}::uuid`;
447
640
  await record(tx, "pairing.cancel", actor.userId, id);
448
641
  });
449
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
+ },
450
666
  listDevices: async (actor: AppApprovalActor, after?: string) => {
451
667
  const cfg = await config(false);
452
668
  const rows = await db<
@@ -457,18 +673,40 @@ export const createAppApprovalService = (
457
673
  nextCursor: rows.length > limits.pageSize ? rows[limits.pageSize - 1]!.id : null,
458
674
  };
459
675
  },
460
- 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> => {
461
682
  const cfg = await config(false);
462
- return db.begin(async (tx) => {
463
- await fresh(tx, actor);
464
- const [device] = await tx<
465
- DeviceRow[]
466
- >`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`;
467
689
  if (!device) return reject("UNAVAILABLE", 404);
468
- if (name !== undefined) await tx`UPDATE auth.app_devices SET name=${name} WHERE id=${id}::uuid`;
469
- else await tx`UPDATE auth.app_devices SET revoked_at=COALESCE(revoked_at,now()) WHERE id=${id}::uuid`;
470
- 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) };
471
707
  });
708
+ if (wakeId) void wake(cfg, actor.userId, wakeId, { accept: "device-changes" });
709
+ return result;
472
710
  },
473
711
  /** Active devices of any account, for administrators. Bounded by the active-device limit. */
474
712
  listUserDevices: async (actor: AppDeviceAdministrator, userId: string): Promise<AppDeviceView[]> => {
@@ -495,7 +733,7 @@ export const createAppApprovalService = (
495
733
  return { device: view(row!), revoked: true };
496
734
  });
497
735
  },
498
- startLogin: async (identifier: string, category: AccountCategory) => {
736
+ startLogin: async (identifier: string, category: AccountCategory, request?: AppLoginRequestContext) => {
499
737
  const cfg = await config();
500
738
  await cleanup();
501
739
  const id = crypto.randomUUID(),
@@ -505,35 +743,68 @@ export const createAppApprovalService = (
505
743
  expiresAt = future(limits.loginSeconds);
506
744
  let owner: string | undefined;
507
745
  await db.begin(async (tx) => {
508
- const candidates = await tx<
509
- AccountRow[]
510
- >`SELECT id,uid,provider,profile,auth_epoch,account_expires FROM auth.users WHERE lower(uid)=lower(${identifier}) OR lower(btrim(mail))=lower(${identifier}) LIMIT 2`;
511
- let candidate = candidates.length === 1 ? candidates[0] : undefined;
512
- if (
513
- candidate &&
514
- (accountCategory(candidate) !== category ||
515
- (candidate.account_expires && new Date(candidate.account_expires).getTime() <= Date.now()) ||
516
- !(await isAccountCategoryAllowed(candidate, tx)))
517
- )
518
- candidate = undefined;
519
- if (candidate) {
520
- await tx`SELECT id FROM auth.users WHERE id=${candidate.id}::uuid FOR UPDATE`;
521
- const [count] = await tx<
522
- { count: number }[]
523
- >`SELECT count(*)::int AS count FROM auth.app_logins WHERE issuer=${cfg.issuer} AND user_id=${candidate.id}::uuid AND expires_at > now() AND state IN ('pending','approved')`;
524
- if ((count?.count ?? 0) >= limits.pendingPerAccount) candidate = undefined;
525
- }
526
- // Unknown, ambiguous, disabled and throttled accounts have the identical
527
- // public shape and a decoy pending transaction; no account enumeration.
528
- await tx`INSERT INTO auth.app_logins(id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at)
529
- VALUES (${id}::uuid,${cfg.issuer},${candidate?.id ?? null}::uuid,${candidate?.auth_epoch ?? null},${category},${hash(browserSecret)},${challenge},${code},${expiresAt})`;
746
+ let candidate = await loginCandidate(tx, identifier, category);
747
+ if (candidate && !(await loginBudget(tx, cfg.issuer, candidate.id))) candidate = undefined;
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)`;
530
757
  owner = candidate?.id;
531
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
+ }
532
767
  // Not awaited: decoy and real requests answer alike, and push is only a wake-up.
533
768
  if (owner) void wake(cfg, owner, id);
534
769
  return { requestId: id, browserSecret, comparison: code, expiresAt: iso(expiresAt), pollAfterSeconds: limits.pollSeconds };
535
770
  },
536
- deviceCommand: async (request: AppDeviceRequest, actions: AppDeviceActions) => {
771
+ startTerminalLogin: async (
772
+ input: AppTerminalLoginInput,
773
+ access: (tx: SQL, account: Pick<AccountRow, "id" | "profile">) => Promise<boolean>,
774
+ ) => {
775
+ const cfg = await config();
776
+ await cleanup();
777
+ const id = crypto.randomUUID(),
778
+ code = comparison(),
779
+ expiresAt = future(limits.loginSeconds);
780
+ let owner: string | undefined;
781
+ await db.begin(async (tx) => {
782
+ let candidate = await loginCandidate(tx, input.identifier);
783
+ const subject = candidate ?? { id: crypto.randomUUID(), profile: "user" as const };
784
+ // Even a decoy performs the client-access, device and shared-budget queries.
785
+ const allowed = await access(tx, subject);
786
+ const devices = await tx`SELECT id FROM auth.app_devices WHERE issuer=${cfg.issuer}
787
+ AND user_id=${subject.id}::uuid AND revoked_at IS NULL LIMIT 1`;
788
+ const budget = await loginBudget(tx, cfg.issuer, subject.id);
789
+ if (!allowed || !devices.length || !budget) candidate = undefined;
790
+ const inserted = await tx`INSERT INTO auth.app_logins
791
+ (id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at,kind,device_authorization_id,client_id,scopes,lifetime_seconds)
792
+ VALUES (${id}::uuid,${cfg.issuer},${candidate?.id ?? null}::uuid,${candidate?.auth_epoch ?? null},
793
+ ${candidate ? accountCategory(candidate) : "login"},${hash(secret())},${secret()},${code},${expiresAt},'terminal',
794
+ ${input.deviceAuthorizationId}::uuid,${input.clientId},${toPgTextArray(input.scopes)}::text[],${input.lifetimeSeconds})
795
+ ON CONFLICT (device_authorization_id) WHERE device_authorization_id IS NOT NULL DO NOTHING RETURNING id`;
796
+ if (!inserted.length) return reject("CONFLICT", 409);
797
+ owner = candidate?.id;
798
+ });
799
+ if (owner) void wake(cfg, owner, id, { accept: "terminal-logins" });
800
+ return { comparison: code, expiresAt: iso(expiresAt) };
801
+ },
802
+ deviceCommand: async (
803
+ request: AppDeviceRequest,
804
+ actions: AppDeviceActions,
805
+ terminal?: AppTerminalLogins,
806
+ options?: { address?: string },
807
+ ) => {
537
808
  const cfg = await config();
538
809
  const { proof, signature } = request;
539
810
  const now = Math.floor(Date.now() / 1000);
@@ -547,8 +818,19 @@ export const createAppApprovalService = (
547
818
  )
548
819
  return reject("FORBIDDEN", 403);
549
820
  await cleanup();
821
+ let decidedBrowserId: string | undefined;
550
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
+ }
551
832
  const device = await activeDevice(tx, proof.deviceId, cfg.issuer);
833
+ if (changeUserId && device.user_id !== changeUserId) return reject("CONFLICT", 409);
552
834
  if (!(await verify(AppDevicePublicKeySchema.parse(device.public_key), appDeviceProofMessage(proof), signature)))
553
835
  return reject("FORBIDDEN", 403);
554
836
  const used =
@@ -556,6 +838,77 @@ export const createAppApprovalService = (
556
838
  if (!used.length) return reject("CONFLICT", 409);
557
839
  await tx`UPDATE auth.app_devices SET last_used_at=now() WHERE id=${device.id}::uuid`;
558
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
+ }
559
912
  if (command.operation === "push") {
560
913
  await tx`UPDATE auth.app_devices SET push_token=${command.token} WHERE id=${device.id}::uuid`;
561
914
  return { state: "updated" as const };
@@ -580,21 +933,72 @@ export const createAppApprovalService = (
580
933
  return { operation: command.operation, userId: device.user_id, deviceId: device.id };
581
934
  if (command.operation === "pending" || command.operation === "inbox") {
582
935
  const user = await eligible(tx, device.user_id);
936
+ const acceptsTerminal =
937
+ command.operation === "inbox" && command.accept?.includes("terminal-logins") === true && terminal !== undefined;
583
938
  const rows = await tx<
584
939
  LoginRow[]
585
- >`SELECT * FROM auth.app_logins WHERE issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid AND state='pending' AND expires_at>now() AND auth_epoch=${user.auth_epoch} AND category=${accountCategory(user)} ORDER BY created_at LIMIT ${limits.pendingPerAccount}`;
586
- const requests = rows.map(
587
- (row): AppPendingLogin => ({
940
+ >`SELECT * FROM auth.app_logins WHERE (kind='browser' OR ${acceptsTerminal}) AND issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid AND state='pending' AND expires_at>now() AND auth_epoch=${user.auth_epoch} AND category=${accountCategory(user)} ORDER BY created_at LIMIT ${limits.pendingPerAccount}`;
941
+ const clients =
942
+ acceptsTerminal && terminal
943
+ ? await terminal.describe(
944
+ tx,
945
+ rows.flatMap((row) => (row.device_authorization_id ? [row.device_authorization_id] : [])),
946
+ )
947
+ : new Map<string, { id: string; name: string }>();
948
+ const requests: AppPendingLogin[] = [];
949
+ for (const row of rows) {
950
+ const login: AppPendingLogin = {
588
951
  requestId: row.id,
589
952
  challenge: row.challenge,
590
953
  comparison: row.comparison,
591
954
  createdAt: iso(row.created_at),
592
955
  expiresAt: iso(row.expires_at),
593
- }),
594
- );
956
+ };
957
+ if (row.kind === "terminal") {
958
+ const client = row.device_authorization_id ? clients.get(row.device_authorization_id) : undefined;
959
+ if (!client || client.id !== row.client_id || !row.scopes || !row.lifetime_seconds) continue;
960
+ login.terminal = {
961
+ client: { id: client.id, name: appApprovalText(client.name || client.id, APP_ACTION_LIMITS.nameChars) || "OAuth" },
962
+ scopes: row.scopes,
963
+ lifetimeSeconds: row.lifetime_seconds,
964
+ };
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
+ }
974
+ requests.push(login);
975
+ }
595
976
  if (command.operation === "inbox") {
596
- await tx`UPDATE auth.app_devices SET actions_at=now() WHERE id=${device.id}::uuid`;
597
- return { operation: "inbox" as const, userId: device.user_id, logins: requests };
977
+ await tx`UPDATE auth.app_devices SET actions_at=now(), accepts=${toPgTextArray(command.accept ?? [])}::text[] WHERE id=${device.id}::uuid`;
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 };
598
1002
  }
599
1003
  return { requests, pollAfterSeconds: limits.pollSeconds };
600
1004
  }
@@ -612,22 +1016,46 @@ export const createAppApprovalService = (
612
1016
  row.category !== accountCategory(user)
613
1017
  )
614
1018
  return reject("CONFLICT", 409);
1019
+ if (
1020
+ row.kind === "terminal" &&
1021
+ (!device.accepts?.includes("terminal-logins") ||
1022
+ !terminal ||
1023
+ !row.device_authorization_id ||
1024
+ !row.client_id ||
1025
+ !(await terminal.decide(tx, {
1026
+ deviceAuthorizationId: row.device_authorization_id,
1027
+ clientId: row.client_id,
1028
+ user,
1029
+ decision: command.decision,
1030
+ })))
1031
+ )
1032
+ return reject("CONFLICT", 409);
615
1033
  const state = command.decision === "approve" ? "approved" : "denied";
616
- await tx`UPDATE auth.app_logins SET state=${state},approved_by=${device.id}::uuid WHERE id=${row.id}::uuid`;
617
- await record(tx, `login.${command.decision}`, device.user_id, device.id, { requestId: row.id });
1034
+ // A hook may wait on another transaction. Expiry still wins before either decision commits.
1035
+ const decided = await tx`UPDATE auth.app_logins SET state=${state},approved_by=${device.id}::uuid
1036
+ WHERE id=${row.id}::uuid AND expires_at > clock_timestamp() RETURNING id`;
1037
+ if (!decided.length) return reject("CONFLICT", 409);
1038
+ const storedContext = row.kind === "browser" ? AppLoginContextSchema.safeParse(row.context) : null;
1039
+ await record(tx, `login.${command.decision}`, device.user_id, device.id, {
1040
+ requestId: row.id,
1041
+ ...(row.kind === "terminal" ? { kind: "terminal", clientId: row.client_id, deviceGrantId: row.device_authorization_id } : {}),
1042
+ ...(storedContext?.success ? { level: storedContext.data.level } : {}),
1043
+ });
1044
+ if (row.kind === "browser") decidedBrowserId = row.id;
618
1045
  return { state };
619
1046
  });
620
1047
  if (outcome.operation !== undefined) {
621
1048
  const command = proof.command;
622
1049
  const input = { userId: outcome.userId, issuer: cfg.issuer };
623
1050
  if (outcome.operation === "inbox") {
624
- const inbox = { logins: outcome.logins, ...(await actions.inbox(input)), pollAfterSeconds: limits.pollSeconds };
625
- // Multibyte summaries share the unchanged response budget with pending sign-ins.
626
- while (inbox.actions.length && new TextEncoder().encode(JSON.stringify(inbox)).length > limits.bodyBytes) {
627
- inbox.actions.pop();
628
- inbox.moreActions++;
629
- }
630
- 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);
631
1059
  }
632
1060
  if (command.operation === "action") {
633
1061
  const view = await actions.view({ ...input, actionId: command.actionId });
@@ -647,15 +1075,26 @@ export const createAppApprovalService = (
647
1075
  }
648
1076
  return reject("INVALID_REQUEST", 400);
649
1077
  }
1078
+ if (outcome.requests !== undefined) {
1079
+ await addSameNetwork(outcome.requests, options?.address);
1080
+ return trimAppLoginResponse(outcome);
1081
+ }
650
1082
  // Only after commit: the waiting browser re-reads Postgres, so a hint never grants anything.
651
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
+ }
652
1091
  return outcome;
653
1092
  },
654
1093
  /** With `hold`, a pending request is answered on its decision, its expiry, or after `hold.ms`, whichever comes first. */
655
1094
  browserStatus: async (id: string, token: string, hold?: { ms: number; signal?: AbortSignal }) => {
656
1095
  const cfg = await config();
657
1096
  const read = async () => {
658
- const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
1097
+ const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer}`;
659
1098
  if (!row || !matches(token, row.browser_hash)) return reject("UNAVAILABLE", 404);
660
1099
  const expiresIn = new Date(row.expires_at).getTime() - Date.now();
661
1100
  return { state: expiresIn <= 0 ? "expired" : row.state, expiresIn };
@@ -686,10 +1125,14 @@ export const createAppApprovalService = (
686
1125
  const cfg = await config();
687
1126
  return db.begin(async (tx) => {
688
1127
  // Lock order is device -> request for both approval and completion.
689
- const [snapshot] = await tx<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
1128
+ const [snapshot] = await tx<
1129
+ LoginRow[]
1130
+ >`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer}`;
690
1131
  if (!snapshot || !snapshot.approved_by || !matches(token, snapshot.browser_hash)) return reject("CONFLICT", 409);
691
1132
  const device = await activeDevice(tx, snapshot.approved_by, cfg.issuer);
692
- const [row] = await tx<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
1133
+ const [row] = await tx<
1134
+ LoginRow[]
1135
+ >`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
693
1136
  const user = await eligible(tx, device.user_id);
694
1137
  if (
695
1138
  !row ||
@@ -705,7 +1148,7 @@ export const createAppApprovalService = (
705
1148
  await record(tx, "login.consume", user.id, device.id, { requestId: id });
706
1149
  // This commit is the authorization boundary. A later issuance/network
707
1150
  // failure requires a new login, never replay of the consumed approval.
708
- return user.id;
1151
+ return { userId: user.id, authEpoch: Number(row.auth_epoch), deviceId: device.id, deviceName: device.name };
709
1152
  });
710
1153
  },
711
1154
  };