@agent-native/core 0.164.4 → 0.164.12

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 (268) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/calendar/.agents/skills/event-management/SKILL.md +84 -0
  3. package/corpus/templates/calendar/AGENTS.md +5 -0
  4. package/corpus/templates/calendar/actions/delete-event.ts +1 -1
  5. package/corpus/templates/calendar/actions/delete-events.ts +508 -0
  6. package/corpus/templates/calendar/actions/event-action-helpers.ts +1 -1
  7. package/corpus/templates/calendar/actions/list-events.ts +53 -0
  8. package/corpus/templates/calendar/app/components/calendar/CreateEventDialog.tsx +36 -47
  9. package/corpus/templates/calendar/app/components/calendar/DayView.tsx +3 -0
  10. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +18 -1
  11. package/corpus/templates/calendar/app/components/calendar/InlineEventPickers.tsx +256 -38
  12. package/corpus/templates/calendar/app/hooks/use-google-auth.ts +43 -3
  13. package/corpus/templates/calendar/app/i18n-data.ts +120 -0
  14. package/corpus/templates/calendar/app/lib/event-form-utils.ts +113 -0
  15. package/corpus/templates/calendar/changelog/2026-08-18-the-agent-can-now-remove-many-meetings-at-once-such-as-every.md +6 -0
  16. package/corpus/templates/calendar/server/handlers/google-auth.ts +119 -3
  17. package/corpus/templates/calendar/server/lib/event-weekday.ts +112 -0
  18. package/corpus/templates/calendar/server/routes/[base]/_agent-native/google/add-account/auth-url.post.ts +1 -0
  19. package/corpus/templates/calendar/server/routes/[base]/_agent-native/google/auth-url.post.ts +1 -0
  20. package/corpus/templates/calendar/server/routes/_agent-native/google/add-account/auth-url.post.ts +1 -0
  21. package/corpus/templates/calendar/server/routes/_agent-native/google/auth-url.post.ts +1 -0
  22. package/corpus/templates/clips/app/components/player/video-player.tsx +12 -0
  23. package/corpus/templates/clips/app/routes/_app.dictate.tsx +18 -4
  24. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +114 -20
  25. package/corpus/templates/clips/app/routes/share.$shareId.tsx +83 -78
  26. package/corpus/templates/clips/changelog/2026-08-19-clips-nightly-builds-are-available-for-trying-the-latest-upd.md +6 -0
  27. package/corpus/templates/clips/changelog/2026-08-19-social-preview-video-frames.md +1 -0
  28. package/corpus/templates/clips/desktop/README.md +19 -4
  29. package/corpus/templates/clips/desktop/src/app.tsx +78 -16
  30. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +3 -2
  31. package/corpus/templates/clips/desktop/src-tauri/tauri.conf.json +1 -0
  32. package/corpus/templates/clips/server/routes/[base]/_agent-native/google/auth-url.post.ts +1 -0
  33. package/corpus/templates/clips/server/routes/_agent-native/google/auth-url.get.ts +41 -3
  34. package/corpus/templates/clips/server/routes/_agent-native/google/auth-url.post.ts +1 -0
  35. package/corpus/templates/clips/server/routes/_agent-native/google/callback.get.ts +22 -1
  36. package/corpus/templates/clips/server/routes/api/clips-updater.json.get.ts +41 -22
  37. package/corpus/templates/factory/AGENTS.md +3 -2
  38. package/corpus/templates/factory/actions/get-factory-graph-version.ts +69 -0
  39. package/corpus/templates/factory/actions/list-factory-automations.ts +4 -0
  40. package/corpus/templates/factory/actions/list-factory-graph-versions.ts +82 -0
  41. package/corpus/templates/factory/actions/restore-factory-graph-version.ts +163 -0
  42. package/corpus/templates/factory/actions/save-factory-graph.ts +93 -60
  43. package/corpus/templates/factory/actions/start-builder-for-item.ts +19 -17
  44. package/corpus/templates/factory/app/components/factory/FactoryAuditView.tsx +100 -36
  45. package/corpus/templates/factory/app/components/factory/FactoryHistoryView.tsx +709 -0
  46. package/corpus/templates/factory/app/components/factory/FactoryInspector.tsx +62 -2
  47. package/corpus/templates/factory/app/components/ui/alert-dialog.tsx +1 -0
  48. package/corpus/templates/factory/app/i18n/en-US.ts +47 -0
  49. package/corpus/templates/factory/app/routes/factory.tsx +324 -61
  50. package/corpus/templates/factory/server/connectors/slack.ts +44 -2
  51. package/corpus/templates/factory/server/plugins/agent-chat.ts +12 -8
  52. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +13 -0
  53. package/corpus/templates/factory/server/triage/slack-client.ts +5 -0
  54. package/corpus/templates/factory/server/triage/slack-poller.ts +72 -4
  55. package/corpus/templates/mail/actions/view-screen.ts +1 -1
  56. package/corpus/templates/mail/app/components/GoogleConnectBanner.tsx +100 -18
  57. package/corpus/templates/mail/server/handlers/google-auth.ts +125 -24
  58. package/corpus/templates/mail/server/lib/gmail-query.ts +5 -2
  59. package/corpus/templates/mail/server/routes/[base]/_agent-native/google/add-account/auth-url.post.ts +1 -0
  60. package/corpus/templates/mail/server/routes/[base]/_agent-native/google/auth-url.post.ts +1 -0
  61. package/corpus/templates/mail/server/routes/_agent-native/google/add-account/auth-url.post.ts +1 -0
  62. package/corpus/templates/mail/server/routes/_agent-native/google/auth-url.post.ts +1 -0
  63. package/corpus/templates/plan/app/pages/PlansPage.tsx +0 -12
  64. package/corpus/templates/plan/changelog/2026-08-19-plan-prompts-now-guide-you-to-connect-ai-before-sending-and-.md +6 -0
  65. package/corpus/templates/slides/actions/approve-deck-access-request.ts +234 -0
  66. package/corpus/templates/slides/actions/get-deck-access-status.ts +35 -2
  67. package/corpus/templates/slides/actions/request-deck-access.ts +263 -64
  68. package/corpus/templates/slides/app/components/editor/MissingDeckAccessPane.tsx +196 -0
  69. package/corpus/templates/slides/app/components/editor/RequestDeckAccessDialog.tsx +101 -0
  70. package/corpus/templates/slides/app/hooks/use-deck-access.ts +7 -4
  71. package/corpus/templates/slides/app/i18n/en-US.ts +29 -6
  72. package/corpus/templates/slides/app/pages/DeckEditor.tsx +147 -169
  73. package/corpus/templates/slides/app/routes/access-request.approve.tsx +250 -0
  74. package/corpus/templates/slides/server/db/schema.ts +8 -0
  75. package/corpus/templates/slides/server/lib/access-request-email.ts +36 -0
  76. package/corpus/templates/slides/server/lib/emails.ts +26 -0
  77. package/corpus/templates/slides/server/plugins/agent-chat.ts +7 -0
  78. package/corpus/templates/slides/server/plugins/db.ts +50 -0
  79. package/corpus/templates/slides/shared/deck-access.ts +31 -0
  80. package/dist/a2a/task-store.d.ts +1 -0
  81. package/dist/a2a/task-store.js +1 -1
  82. package/dist/agent/observational-memory/store.d.ts +1 -0
  83. package/dist/agent/observational-memory/store.js +1 -1
  84. package/dist/agent/run-store.d.ts +1 -0
  85. package/dist/agent/run-store.js +1 -1
  86. package/dist/agent/tool-approval-store.d.ts +1 -0
  87. package/dist/agent/tool-approval-store.js +1 -1
  88. package/dist/application-state/store.d.ts +1 -0
  89. package/dist/application-state/store.js +1 -1
  90. package/dist/browser-sessions/store.d.ts +1 -0
  91. package/dist/browser-sessions/store.js +1 -1
  92. package/dist/checkpoints/store.d.ts +1 -0
  93. package/dist/checkpoints/store.js +1 -1
  94. package/dist/cli/code-agent-schedules.d.ts +11 -0
  95. package/dist/cli/code-agent-schedules.js +44 -5
  96. package/dist/client/AgentPanel.d.ts +5 -1
  97. package/dist/client/AgentPanel.js +2 -2
  98. package/dist/client/AssistantChat.d.ts +5 -1
  99. package/dist/client/AssistantChat.js +40 -6
  100. package/dist/client/EnvironmentBadge.d.ts +7 -13
  101. package/dist/client/EnvironmentBadge.js +43 -92
  102. package/dist/client/agent-chat/index.d.ts +1 -0
  103. package/dist/client/agent-chat/index.js +1 -0
  104. package/dist/client/agent-chat-adapter.js +76 -50
  105. package/dist/client/app-providers.js +4 -4
  106. package/dist/client/chat/message-components.d.ts +4 -0
  107. package/dist/client/chat/message-components.js +28 -1
  108. package/dist/client/chat/run-recovery.js +26 -0
  109. package/dist/client/chat-first/apps-rail.d.ts +1 -0
  110. package/dist/client/chat-first/apps-rail.js +28 -2
  111. package/dist/client/sse-event-processor.js +13 -8
  112. package/dist/client/use-active-agent-chat-run.d.ts +2 -0
  113. package/dist/client/use-active-agent-chat-run.js +22 -0
  114. package/dist/coding-tools/sandbox/executions-store.d.ts +1 -0
  115. package/dist/coding-tools/sandbox/executions-store.js +1 -1
  116. package/dist/collab/awareness-store.d.ts +1 -0
  117. package/dist/collab/awareness-store.js +1 -1
  118. package/dist/collab/routes.d.ts +1 -1
  119. package/dist/collab/storage.d.ts +1 -0
  120. package/dist/collab/storage.js +1 -1
  121. package/dist/collab/struct-routes.d.ts +1 -1
  122. package/dist/db/client.js +3 -3
  123. package/dist/db/ddl-guard.js +8 -0
  124. package/dist/db/migration-runtime.d.ts +23 -0
  125. package/dist/db/migration-runtime.js +41 -0
  126. package/dist/db/migrations.d.ts +1 -2
  127. package/dist/db/migrations.js +2 -24
  128. package/dist/email-catalog/log.d.ts +1 -0
  129. package/dist/email-catalog/log.js +1 -1
  130. package/dist/guards/index.d.ts +1 -0
  131. package/dist/guards/index.js +1 -0
  132. package/dist/guards/release-schema-complete.d.ts +22 -0
  133. package/dist/guards/release-schema-complete.js +144 -0
  134. package/dist/integrations/a2a-continuations-store.d.ts +1 -0
  135. package/dist/integrations/a2a-continuations-store.js +1 -1
  136. package/dist/integrations/awaiting-input-store.d.ts +1 -0
  137. package/dist/integrations/awaiting-input-store.js +1 -1
  138. package/dist/integrations/config-store.d.ts +1 -0
  139. package/dist/integrations/config-store.js +1 -1
  140. package/dist/integrations/controls-store.d.ts +1 -0
  141. package/dist/integrations/controls-store.js +1 -1
  142. package/dist/integrations/identity-links-store.d.ts +1 -0
  143. package/dist/integrations/identity-links-store.js +1 -1
  144. package/dist/integrations/installations-store.d.ts +1 -0
  145. package/dist/integrations/installations-store.js +1 -1
  146. package/dist/integrations/remote-commands-store.d.ts +1 -0
  147. package/dist/integrations/remote-commands-store.js +1 -1
  148. package/dist/integrations/remote-devices-store.d.ts +1 -0
  149. package/dist/integrations/remote-devices-store.js +1 -1
  150. package/dist/integrations/remote-push-store.d.ts +1 -0
  151. package/dist/integrations/remote-push-store.js +1 -1
  152. package/dist/integrations/remote-run-events-store.d.ts +1 -0
  153. package/dist/integrations/remote-run-events-store.js +1 -1
  154. package/dist/integrations/scope-store.d.ts +1 -0
  155. package/dist/integrations/scope-store.js +1 -1
  156. package/dist/integrations/thread-mapping-store.d.ts +1 -0
  157. package/dist/integrations/thread-mapping-store.js +1 -1
  158. package/dist/integrations/usage-budget-store.d.ts +1 -0
  159. package/dist/integrations/usage-budget-store.js +1 -1
  160. package/dist/jobs/background-automation-runner.js +111 -9
  161. package/dist/jobs/run-history.d.ts +1 -0
  162. package/dist/jobs/run-history.js +1 -1
  163. package/dist/jobs/scheduler-health.d.ts +1 -0
  164. package/dist/jobs/scheduler-health.js +1 -1
  165. package/dist/mcp/approval-store.d.ts +1 -0
  166. package/dist/mcp/approval-store.js +1 -1
  167. package/dist/mcp/connect-store.d.ts +1 -0
  168. package/dist/mcp/connect-store.js +1 -1
  169. package/dist/mcp/oauth-store.d.ts +1 -0
  170. package/dist/mcp/oauth-store.js +1 -1
  171. package/dist/notifications/store.d.ts +1 -0
  172. package/dist/notifications/store.js +1 -1
  173. package/dist/oauth-tokens/store.d.ts +1 -0
  174. package/dist/oauth-tokens/store.js +1 -1
  175. package/dist/observability/routes.d.ts +5 -5
  176. package/dist/progress/routes.d.ts +1 -1
  177. package/dist/progress/store.d.ts +1 -0
  178. package/dist/progress/store.js +1 -1
  179. package/dist/provider-api/actions/custom-provider-registration.d.ts +5 -5
  180. package/dist/provider-api/actions/provider-api.d.ts +4 -4
  181. package/dist/provider-api/corpus-jobs-store.d.ts +1 -0
  182. package/dist/provider-api/corpus-jobs-store.js +1 -1
  183. package/dist/provider-api/custom-registry.d.ts +1 -0
  184. package/dist/provider-api/custom-registry.js +1 -1
  185. package/dist/provider-api/quota-governor.d.ts +1 -0
  186. package/dist/provider-api/quota-governor.js +1 -1
  187. package/dist/provider-api/staged-datasets-store.d.ts +1 -0
  188. package/dist/provider-api/staged-datasets-store.js +1 -1
  189. package/dist/resources/handlers.d.ts +3 -3
  190. package/dist/resources/store.d.ts +1 -0
  191. package/dist/resources/store.js +1 -1
  192. package/dist/scripts/dev/index.js +15 -1
  193. package/dist/search/index.js +3 -0
  194. package/dist/secrets/routes.d.ts +9 -9
  195. package/dist/secrets/storage.d.ts +1 -0
  196. package/dist/secrets/storage.js +1 -1
  197. package/dist/server/agent-chat-plugin.js +38 -9
  198. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  199. package/dist/server/agent-teams-run-queue.d.ts +1 -0
  200. package/dist/server/agent-teams-run-queue.js +1 -1
  201. package/dist/server/auth-marketing.js +3 -1
  202. package/dist/server/auth.d.ts +18 -1
  203. package/dist/server/auth.js +370 -154
  204. package/dist/server/embed-session.d.ts +1 -0
  205. package/dist/server/embed-session.js +1 -1
  206. package/dist/server/google-auth-plugin.js +3 -1
  207. package/dist/server/google-oauth.d.ts +10 -0
  208. package/dist/server/google-oauth.js +25 -1
  209. package/dist/server/identity-sso-store.d.ts +1 -0
  210. package/dist/server/identity-sso-store.js +1 -1
  211. package/dist/server/index.d.ts +1 -1
  212. package/dist/server/index.js +1 -1
  213. package/dist/server/onboarding-html.js +200 -31
  214. package/dist/server/poll.d.ts +2 -0
  215. package/dist/server/poll.js +19 -1
  216. package/dist/server/realtime-token.d.ts +1 -1
  217. package/dist/server/release-migrations.js +5 -0
  218. package/dist/server/release-schema.d.ts +46 -0
  219. package/dist/server/release-schema.js +307 -0
  220. package/dist/settings/store.d.ts +1 -0
  221. package/dist/settings/store.js +1 -1
  222. package/dist/shared/environment-lanes.d.ts +28 -0
  223. package/dist/shared/environment-lanes.js +61 -0
  224. package/dist/sharing/actions/share-resource.js +22 -2
  225. package/dist/styles/agent-native.css +17 -15
  226. package/dist/triggers/routes.d.ts +11 -0
  227. package/dist/triggers/routes.js +10 -0
  228. package/dist/usage/alerts-store.d.ts +1 -0
  229. package/dist/usage/alerts-store.js +1 -1
  230. package/dist/usage/store.d.ts +1 -0
  231. package/dist/usage/store.js +1 -1
  232. package/docs/content/actions-access-control.mdx +136 -2
  233. package/docs/content/actions-agent-tools.mdx +101 -0
  234. package/docs/content/actions-defining.mdx +2 -2
  235. package/docs/content/audit-log.mdx +1 -1
  236. package/docs/content/authentication.mdx +1 -1
  237. package/docs/content/cloudflare.mdx +43 -0
  238. package/docs/content/creating-templates.mdx +1 -1
  239. package/docs/content/deploy-an-app.mdx +109 -0
  240. package/docs/content/deployment-environment-variables.mdx +101 -0
  241. package/docs/content/deployment.mdx +125 -792
  242. package/docs/content/docs-components.mdx +2 -2
  243. package/docs/content/environment-variables.mdx +41 -41
  244. package/docs/content/file-uploads.mdx +74 -40
  245. package/docs/content/harness-agents.mdx +2 -2
  246. package/docs/content/internationalization.mdx +61 -65
  247. package/docs/content/key-concepts.mdx +3 -3
  248. package/docs/content/multi-app-workspace.mdx +2 -2
  249. package/docs/content/netlify.mdx +47 -0
  250. package/docs/content/node-docker.mdx +138 -0
  251. package/docs/content/organizations-teams-permissions.mdx +5 -5
  252. package/docs/content/other-platforms.mdx +27 -0
  253. package/docs/content/ssr-caching.mdx +45 -0
  254. package/docs/content/template-assets-developers.mdx +2 -2
  255. package/docs/content/template-chat-developers.mdx +2 -2
  256. package/docs/content/template-content-developers.mdx +2 -2
  257. package/docs/content/{local-file-mode.mdx → template-content-local-files.mdx} +2 -1
  258. package/docs/content/template-content-sync.mdx +2 -2
  259. package/docs/content/toolkit-agent-ux.mdx +1 -1
  260. package/docs/content/toolkit-resources.mdx +2 -2
  261. package/docs/content/updating-ui-in-production.mdx +35 -0
  262. package/docs/content/vercel.mdx +38 -0
  263. package/docs/content/what-is-agent-native.mdx +1 -1
  264. package/docs/content/workspace-deployment.mdx +350 -0
  265. package/package.json +2 -2
  266. package/docs/content/agents.mdx +0 -9
  267. package/docs/content/database.mdx +0 -317
  268. package/docs/content/human-approval.mdx +0 -156
@@ -842,7 +842,7 @@ export function isExpectedAuthFailure(error) {
842
842
  // ---------------------------------------------------------------------------
843
843
  let _sessionInitPromise;
844
844
  let sessionMaxAge = DEFAULT_MAX_AGE;
845
- async function ensureSessionTable() {
845
+ export async function ensureSessionTable() {
846
846
  if (!_sessionInitPromise) {
847
847
  _sessionInitPromise = (async () => {
848
848
  const client = getDbExec();
@@ -1115,6 +1115,7 @@ const _desktopExchanges = new Map();
1115
1115
  const DESKTOP_EXCHANGE_ERROR_PREFIX = "__error__::";
1116
1116
  const DESKTOP_MAGIC_LINK_CHALLENGE_PREFIX = "__magic-link-challenge__::";
1117
1117
  const DESKTOP_MAGIC_LINK_EXCHANGE_PREFIX = "__magic-link-exchange__::";
1118
+ export const DESKTOP_OAUTH_BROWSER_BINDING_COOKIE = "an_desktop_oauth_binding";
1118
1119
  const DESKTOP_AUTH_TOKEN_BODY_ORIGINS = new Set([
1119
1120
  "tauri://localhost",
1120
1121
  "http://tauri.localhost",
@@ -1138,6 +1139,12 @@ function normalizeDesktopFlowVerifier(value) {
1138
1139
  function desktopFlowVerifierHash(verifier) {
1139
1140
  return crypto.createHash("sha256").update(verifier).digest("base64url");
1140
1141
  }
1142
+ function matchesDesktopHash(actualHash, expectedHash) {
1143
+ const actual = Buffer.from(actualHash);
1144
+ const expected = Buffer.from(expectedHash);
1145
+ return (actual.length === expected.length &&
1146
+ crypto.timingSafeEqual(actual, expected));
1147
+ }
1141
1148
  function matchesDesktopFlowVerifier(verifier, expectedHash) {
1142
1149
  const actual = Buffer.from(desktopFlowVerifierHash(verifier));
1143
1150
  const expected = Buffer.from(expectedHash);
@@ -1145,31 +1152,63 @@ function matchesDesktopFlowVerifier(verifier, expectedHash) {
1145
1152
  crypto.timingSafeEqual(actual, expected));
1146
1153
  }
1147
1154
  function parseDesktopExchangeStoredEntry(packed) {
1148
- if (packed.startsWith(DESKTOP_MAGIC_LINK_CHALLENGE_PREFIX)) {
1149
- const verifierHash = packed.slice(DESKTOP_MAGIC_LINK_CHALLENGE_PREFIX.length);
1150
- return verifierHash ? { challenge: true, verifierHash } : null;
1151
- }
1152
- if (packed.startsWith(DESKTOP_EXCHANGE_ERROR_PREFIX)) {
1153
- const raw = packed.slice(DESKTOP_EXCHANGE_ERROR_PREFIX.length);
1155
+ try {
1156
+ if (packed.startsWith(DESKTOP_MAGIC_LINK_CHALLENGE_PREFIX)) {
1157
+ const raw = packed.slice(DESKTOP_MAGIC_LINK_CHALLENGE_PREFIX.length);
1158
+ const parts = raw.split("::");
1159
+ if (parts.length > 2)
1160
+ return null;
1161
+ const [verifierHash, browserBindingHash] = parts;
1162
+ if (!isValidDesktopFlowVerifierHash(verifierHash))
1163
+ return null;
1164
+ if (browserBindingHash !== undefined &&
1165
+ !isValidDesktopFlowVerifierHash(browserBindingHash)) {
1166
+ return null;
1167
+ }
1168
+ return {
1169
+ challenge: true,
1170
+ verifierHash,
1171
+ ...(browserBindingHash ? { browserBindingHash } : {}),
1172
+ };
1173
+ }
1174
+ if (packed.startsWith(DESKTOP_EXCHANGE_ERROR_PREFIX)) {
1175
+ const raw = packed.slice(DESKTOP_EXCHANGE_ERROR_PREFIX.length);
1176
+ const parsed = JSON.parse(Buffer.from(raw, "base64url").toString());
1177
+ if (!parsed ||
1178
+ typeof parsed !== "object" ||
1179
+ typeof parsed.message !== "string") {
1180
+ return null;
1181
+ }
1182
+ return { error: parsed };
1183
+ }
1184
+ const isMagicLinkExchange = packed.startsWith(DESKTOP_MAGIC_LINK_EXCHANGE_PREFIX);
1185
+ const encoded = isMagicLinkExchange
1186
+ ? packed.slice(DESKTOP_MAGIC_LINK_EXCHANGE_PREFIX.length)
1187
+ : packed;
1188
+ const verifierSeparator = isMagicLinkExchange ? encoded.indexOf("::") : -1;
1189
+ const verifierHash = verifierSeparator >= 0 ? encoded.slice(0, verifierSeparator) : undefined;
1190
+ if (verifierHash !== undefined &&
1191
+ !isValidDesktopFlowVerifierHash(verifierHash)) {
1192
+ return null;
1193
+ }
1194
+ const tokenAndEmail = verifierSeparator >= 0 ? encoded.slice(verifierSeparator + 2) : encoded;
1195
+ const sepIdx = tokenAndEmail.indexOf("::");
1196
+ if (sepIdx <= 0 || sepIdx === tokenAndEmail.length - 2)
1197
+ return null;
1198
+ const token = tokenAndEmail.slice(0, sepIdx);
1199
+ const email = tokenAndEmail.slice(sepIdx + 2);
1200
+ if (!token || !email)
1201
+ return null;
1154
1202
  return {
1155
- error: JSON.parse(Buffer.from(raw, "base64url").toString()),
1203
+ token,
1204
+ email,
1205
+ ...(verifierHash ? { verifierHash } : {}),
1156
1206
  };
1157
1207
  }
1158
- const isMagicLinkExchange = packed.startsWith(DESKTOP_MAGIC_LINK_EXCHANGE_PREFIX);
1159
- const encoded = isMagicLinkExchange
1160
- ? packed.slice(DESKTOP_MAGIC_LINK_EXCHANGE_PREFIX.length)
1161
- : packed;
1162
- const verifierSeparator = isMagicLinkExchange ? encoded.indexOf("::") : -1;
1163
- const verifierHash = verifierSeparator >= 0 ? encoded.slice(0, verifierSeparator) : undefined;
1164
- const tokenAndEmail = verifierSeparator >= 0 ? encoded.slice(verifierSeparator + 2) : encoded;
1165
- const sepIdx = tokenAndEmail.indexOf("::");
1166
- if (sepIdx === -1)
1208
+ catch {
1209
+ // coercion-ok: malformed desktop exchange payloads are rejected as absent.
1167
1210
  return null;
1168
- return {
1169
- token: tokenAndEmail.slice(0, sepIdx),
1170
- email: tokenAndEmail.slice(sepIdx + 2),
1171
- ...(verifierHash ? { verifierHash } : {}),
1172
- };
1211
+ }
1173
1212
  }
1174
1213
  function isDesktopMagicLinkCallbackPath(value) {
1175
1214
  try {
@@ -1186,20 +1225,49 @@ function withDesktopMagicLinkFlow(callbackURL, flow) {
1186
1225
  url.searchParams.set("verifier", flow.verifier);
1187
1226
  return `${url.pathname}${url.search}${url.hash}`;
1188
1227
  }
1189
- async function persistDesktopMagicLinkChallenge(flowId, verifierHash) {
1190
- await addSession(`dex:${flowId}`, `${DESKTOP_MAGIC_LINK_CHALLENGE_PREFIX}${verifierHash}`);
1228
+ async function persistDesktopMagicLinkChallenge(flowId, verifierHash, browserBindingHash) {
1229
+ await addSession(`dex:${flowId}`, `${DESKTOP_MAGIC_LINK_CHALLENGE_PREFIX}${verifierHash}${browserBindingHash ? `::${browserBindingHash}` : ""}`);
1230
+ }
1231
+ function isValidDesktopFlowVerifierHash(value) {
1232
+ return typeof value === "string" && /^[A-Za-z0-9_-]{43}$/.test(value);
1233
+ }
1234
+ /**
1235
+ * Create or reuse the browser-local binding for a desktop Google OAuth flow.
1236
+ * The raw value stays in an HttpOnly cookie; only its hash travels in signed
1237
+ * OAuth state and the exchange challenge. This prevents a caller from
1238
+ * initiating a flow and handing its authorization URL to another browser.
1239
+ */
1240
+ export function prepareDesktopOAuthBrowserBinding(event) {
1241
+ let binding = getCookie(event, DESKTOP_OAUTH_BROWSER_BINDING_COOKIE);
1242
+ if (!binding || !/^[A-Za-z0-9_-]{43}$/.test(binding)) {
1243
+ binding = crypto.randomBytes(32).toString("base64url");
1244
+ setCookie(event, DESKTOP_OAUTH_BROWSER_BINDING_COOKIE, binding, {
1245
+ ...crossSiteCookieAttrs(event),
1246
+ httpOnly: true,
1247
+ path: "/",
1248
+ maxAge: Math.floor(DESKTOP_EXCHANGE_TTL_MS / 1_000),
1249
+ });
1250
+ }
1251
+ return desktopFlowVerifierHash(binding);
1252
+ }
1253
+ /** Verify that a desktop OAuth callback is returning to its initiating browser. */
1254
+ export function matchesDesktopOAuthBrowserBinding(event, expectedHash) {
1255
+ const binding = getCookie(event, DESKTOP_OAUTH_BROWSER_BINDING_COOKIE);
1256
+ return Boolean(binding &&
1257
+ isValidDesktopFlowVerifierHash(expectedHash) &&
1258
+ matchesDesktopFlowVerifier(binding, expectedHash));
1191
1259
  }
1192
1260
  async function issueDesktopMagicLinkFlow() {
1193
1261
  const flowId = crypto.randomBytes(32).toString("base64url");
1194
1262
  const verifier = crypto.randomBytes(32).toString("base64url");
1195
1263
  const verifierHash = desktopFlowVerifierHash(verifier);
1196
- _desktopExchanges.set(flowId, {
1197
- challenge: true,
1198
- verifierHash,
1199
- expiresAt: Date.now() + DESKTOP_EXCHANGE_TTL_MS,
1200
- });
1201
1264
  try {
1202
1265
  await persistDesktopMagicLinkChallenge(flowId, verifierHash);
1266
+ _desktopExchanges.set(flowId, {
1267
+ challenge: true,
1268
+ verifierHash,
1269
+ expiresAt: Date.now() + DESKTOP_EXCHANGE_TTL_MS,
1270
+ });
1203
1271
  }
1204
1272
  catch (error) {
1205
1273
  _desktopExchanges.delete(flowId);
@@ -1207,16 +1275,88 @@ async function issueDesktopMagicLinkFlow() {
1207
1275
  }
1208
1276
  return { flowId, verifier };
1209
1277
  }
1210
- export function setDesktopExchange(flowId, token, email) {
1211
- _desktopExchanges.set(flowId, {
1212
- token,
1213
- email,
1214
- expiresAt: Date.now() + DESKTOP_EXCHANGE_TTL_MS,
1215
- });
1216
- // Persist to DB so the token survives cross-instance routing (e.g. when
1217
- // templates call this helper directly instead of going through the OAuth
1218
- // callback path).
1219
- void persistDesktopExchangeToDB(flowId, token, email);
1278
+ export async function setDesktopExchange(flowId, token, email, verifierHash) {
1279
+ if (!isValidDesktopFlowVerifierHash(verifierHash)) {
1280
+ throw new Error("Invalid desktop exchange challenge.");
1281
+ }
1282
+ // Persist before publishing anything to the in-memory cache. The DB is the
1283
+ // durable source of truth for one-time tokens; publishing first would let a
1284
+ // same-instance poll succeed while a delayed write recreated a token after
1285
+ // it had already been consumed.
1286
+ await persistDesktopExchangeToDB(flowId, token, email, verifierHash);
1287
+ _desktopExchanges.delete(flowId);
1288
+ }
1289
+ /**
1290
+ * Register the client-held verifier for a desktop OAuth flow before Google
1291
+ * sees the request. Only the verifier hash is persisted; the initiating
1292
+ * browser keeps the raw verifier and presents it when polling the exchange.
1293
+ */
1294
+ export async function registerDesktopExchange(flowId, verifier, browserBindingHash) {
1295
+ const normalizedFlowId = normalizeDesktopFlowId(flowId);
1296
+ const normalizedVerifier = normalizeDesktopFlowVerifier(verifier);
1297
+ if (!normalizedFlowId || !normalizedVerifier) {
1298
+ throw new Error("Invalid desktop exchange challenge.");
1299
+ }
1300
+ if (browserBindingHash !== undefined &&
1301
+ !isValidDesktopFlowVerifierHash(browserBindingHash)) {
1302
+ throw new Error("Invalid desktop exchange browser binding.");
1303
+ }
1304
+ const verifierHash = desktopFlowVerifierHash(normalizedVerifier);
1305
+ const current = _desktopExchanges.get(normalizedFlowId);
1306
+ if (current && current.expiresAt >= Date.now()) {
1307
+ if ("challenge" in current &&
1308
+ matchesDesktopFlowVerifier(normalizedVerifier, current.verifierHash)) {
1309
+ if (browserBindingHash !== undefined &&
1310
+ (!current.browserBindingHash ||
1311
+ !matchesDesktopHash(current.browserBindingHash, browserBindingHash))) {
1312
+ throw new Error("Desktop exchange flow is already in use.");
1313
+ }
1314
+ return current.verifierHash;
1315
+ }
1316
+ throw new Error("Desktop exchange flow is already in use.");
1317
+ }
1318
+ const stored = await readDesktopExchangeFromDB(normalizedFlowId);
1319
+ if (stored.status === "unavailable") {
1320
+ throw new Error("Desktop exchange storage is unavailable.");
1321
+ }
1322
+ if (stored.status === "malformed") {
1323
+ throw new Error("Desktop exchange storage is invalid.");
1324
+ }
1325
+ if (stored.status === "entry") {
1326
+ const storedEntry = stored.entry;
1327
+ if ("challenge" in storedEntry &&
1328
+ matchesDesktopFlowVerifier(normalizedVerifier, storedEntry.verifierHash)) {
1329
+ if (browserBindingHash !== undefined &&
1330
+ (!storedEntry.browserBindingHash ||
1331
+ !matchesDesktopHash(storedEntry.browserBindingHash, browserBindingHash))) {
1332
+ throw new Error("Desktop exchange flow is already in use.");
1333
+ }
1334
+ _desktopExchanges.set(normalizedFlowId, {
1335
+ challenge: true,
1336
+ verifierHash: storedEntry.verifierHash,
1337
+ ...(storedEntry.browserBindingHash
1338
+ ? { browserBindingHash: storedEntry.browserBindingHash }
1339
+ : {}),
1340
+ expiresAt: Date.now() + DESKTOP_EXCHANGE_TTL_MS,
1341
+ });
1342
+ return storedEntry.verifierHash;
1343
+ }
1344
+ throw new Error("Desktop exchange flow is already in use.");
1345
+ }
1346
+ try {
1347
+ await persistDesktopMagicLinkChallenge(normalizedFlowId, verifierHash, browserBindingHash);
1348
+ _desktopExchanges.set(normalizedFlowId, {
1349
+ challenge: true,
1350
+ verifierHash,
1351
+ ...(browserBindingHash ? { browserBindingHash } : {}),
1352
+ expiresAt: Date.now() + DESKTOP_EXCHANGE_TTL_MS,
1353
+ });
1354
+ }
1355
+ catch (error) {
1356
+ _desktopExchanges.delete(normalizedFlowId);
1357
+ throw error;
1358
+ }
1359
+ return verifierHash;
1220
1360
  }
1221
1361
  export function setDesktopExchangeError(flowId, error) {
1222
1362
  _desktopExchanges.set(flowId, {
@@ -1230,19 +1370,15 @@ export function setDesktopExchangeError(flowId, error) {
1230
1370
  * cross-instance routing (e.g. Cloudflare Workers). Stored under a synthetic
1231
1371
  * token key "dex:{flowId}"; the `email` column packs both the real session
1232
1372
  * token and the user email so they can be recovered in one query.
1233
- * Non-fatal — if the DB isn't ready yet the in-memory Map still works for
1234
- * same-instance requests.
1373
+ * Fail closed when the durable exchange cannot be written. A token must not
1374
+ * be published only in memory because the callback and poll can land on
1375
+ * different instances.
1235
1376
  */
1236
1377
  async function persistDesktopExchangeToDB(flowId, token, email, verifierHash) {
1237
- try {
1238
- const packed = verifierHash
1239
- ? `${DESKTOP_MAGIC_LINK_EXCHANGE_PREFIX}${verifierHash}::${token}::${email}`
1240
- : `${token}::${email}`;
1241
- await addSession(`dex:${flowId}`, packed);
1242
- }
1243
- catch {
1244
- // non-fatal — in-memory Map is the primary path
1245
- }
1378
+ const packed = verifierHash
1379
+ ? `${DESKTOP_MAGIC_LINK_EXCHANGE_PREFIX}${verifierHash}::${token}::${email}`
1380
+ : `${token}::${email}`;
1381
+ await addSession(`dex:${flowId}`, packed);
1246
1382
  }
1247
1383
  async function persistDesktopExchangeErrorToDB(flowId, error) {
1248
1384
  try {
@@ -1253,41 +1389,35 @@ async function persistDesktopExchangeErrorToDB(flowId, error) {
1253
1389
  // non-fatal — in-memory Map is the primary path
1254
1390
  }
1255
1391
  }
1256
- /**
1257
- * Retrieve and consume a desktop exchange entry from the DB fallback.
1258
- * Returns null if not found or already consumed.
1259
- */
1260
- async function consumeDesktopExchangeFromDB(flowId) {
1392
+ /** Read the durable exchange without mutating it. */
1393
+ async function readDesktopExchangeFromDB(flowId) {
1261
1394
  try {
1262
- // Atomic DELETE...RETURNING prevents token replay: two concurrent polls
1263
- // cannot both retrieve the token because only one DELETE will match the row.
1264
- // SQLite ≥3.35 and PostgreSQL both support this syntax.
1265
- // The created_at predicate enforces the 5-minute TTL so stale DB entries
1266
- // (e.g. the desktop app never polled) are rejected rather than silently
1267
- // redeemed with the session table's default 30-day TTL.
1268
1395
  const client = getDbExec();
1269
1396
  const { rows } = await client.execute({
1270
- sql: `DELETE FROM sessions WHERE token = ? AND created_at > ? AND email NOT LIKE ? RETURNING email`,
1271
- args: [
1272
- `dex:${flowId}`,
1273
- Date.now() - DESKTOP_EXCHANGE_TTL_MS,
1274
- `${DESKTOP_MAGIC_LINK_CHALLENGE_PREFIX}%`,
1275
- ],
1397
+ sql: `SELECT email FROM sessions WHERE token = ? AND created_at > ? LIMIT 1`,
1398
+ args: [`dex:${flowId}`, Date.now() - DESKTOP_EXCHANGE_TTL_MS],
1276
1399
  });
1277
- forgetCachedSessionEmail(`dex:${flowId}`);
1278
1400
  if (rows.length === 0)
1279
- return null;
1401
+ return { status: "missing" };
1280
1402
  const packed = (rows[0].email ?? rows[0][0]);
1281
- if (!packed)
1282
- return null;
1283
- return parseDesktopExchangeStoredEntry(packed);
1403
+ if (packed === null)
1404
+ return { status: "malformed", packed };
1405
+ const entry = parseDesktopExchangeStoredEntry(packed);
1406
+ return entry
1407
+ ? { status: "entry", entry, packed }
1408
+ : { status: "malformed", packed };
1284
1409
  }
1285
1410
  catch {
1286
- // coercion-ok: a DB fallback outage leaves the exchange pending so polling can retry without consuming a token.
1287
- return null;
1411
+ return { status: "unavailable" };
1288
1412
  }
1289
1413
  }
1290
- async function readDesktopMagicLinkChallengeFromDB(flowId) {
1414
+ /**
1415
+ * Parse and atomically consume a desktop exchange entry from the DB fallback.
1416
+ * The exact packed payload is part of the DELETE predicate so concurrent
1417
+ * pollers cannot both redeem a row, and malformed payloads remain available
1418
+ * for diagnosis instead of being deleted before parsing.
1419
+ */
1420
+ async function consumeDesktopExchangeFromDB(flowId) {
1291
1421
  try {
1292
1422
  const client = getDbExec();
1293
1423
  const { rows } = await client.execute({
@@ -1295,17 +1425,27 @@ async function readDesktopMagicLinkChallengeFromDB(flowId) {
1295
1425
  args: [`dex:${flowId}`, Date.now() - DESKTOP_EXCHANGE_TTL_MS],
1296
1426
  });
1297
1427
  if (rows.length === 0)
1298
- return null;
1428
+ return { status: "missing" };
1299
1429
  const packed = (rows[0].email ?? rows[0][0]);
1300
- if (!packed)
1301
- return null;
1302
- const entry = parseDesktopExchangeStoredEntry(packed);
1303
- return entry && "challenge" in entry
1304
- ? { verifierHash: entry.verifierHash }
1305
- : null;
1430
+ const entry = packed ? parseDesktopExchangeStoredEntry(packed) : null;
1431
+ if (!entry)
1432
+ return { status: "malformed", packed };
1433
+ // SQLite >=3.35 and PostgreSQL both support RETURNING. Matching the
1434
+ // payload makes the read/claim pair safe against a concurrent replacement
1435
+ // of the same flow id, while the single DELETE makes concurrent pollers
1436
+ // one-time consumers.
1437
+ const deleted = await client.execute({
1438
+ sql: `DELETE FROM sessions WHERE token = ? AND created_at > ? AND email = ? RETURNING email`,
1439
+ args: [`dex:${flowId}`, Date.now() - DESKTOP_EXCHANGE_TTL_MS, packed],
1440
+ });
1441
+ if (deleted.rows.length === 0)
1442
+ return { status: "missing" };
1443
+ forgetCachedSessionEmail(`dex:${flowId}`);
1444
+ return { status: "entry", entry };
1306
1445
  }
1307
1446
  catch {
1308
- return null;
1447
+ // coercion-ok: a DB fallback outage leaves the exchange pending so polling can retry without consuming a token.
1448
+ return { status: "unavailable" };
1309
1449
  }
1310
1450
  }
1311
1451
  async function claimDesktopMagicLinkFlow(flowId, verifier, token, email) {
@@ -1317,14 +1457,15 @@ async function claimDesktopMagicLinkFlow(flowId, verifier, token, email) {
1317
1457
  !matchesDesktopFlowVerifier(verifier, entry.verifierHash)) {
1318
1458
  return false;
1319
1459
  }
1320
- _desktopExchanges.set(flowId, {
1321
- token,
1322
- email,
1323
- verifierHash,
1324
- expiresAt: Date.now() + DESKTOP_EXCHANGE_TTL_MS,
1325
- });
1326
- await persistDesktopExchangeToDB(flowId, token, email, verifierHash);
1327
- return true;
1460
+ try {
1461
+ await persistDesktopExchangeToDB(flowId, token, email, verifierHash);
1462
+ _desktopExchanges.delete(flowId);
1463
+ return true;
1464
+ }
1465
+ catch {
1466
+ // coercion-ok: a failed persistence claim must not issue a native session token.
1467
+ return false;
1468
+ }
1328
1469
  }
1329
1470
  try {
1330
1471
  const client = getDbExec();
@@ -1440,6 +1581,7 @@ function applyCorsHeaders(event, publicCorsPaths = [], requestPath) {
1440
1581
  "X-Request-Source",
1441
1582
  "X-Agent-Native-CSRF",
1442
1583
  "X-User-Timezone",
1584
+ "X-Agent-Native-Desktop-Verifier",
1443
1585
  EMBED_TARGET_HEADER,
1444
1586
  ].join(","));
1445
1587
  return { hasOrigin: true, allowed: true };
@@ -2911,10 +3053,11 @@ async function mountBetterAuthRoutes(app, options) {
2911
3053
  "https://www.googleapis.com/auth/userinfo.email",
2912
3054
  "https://www.googleapis.com/auth/userinfo.profile",
2913
3055
  ].join(" ");
2914
- app.use("/_agent-native/google/auth-url", defineEventHandler((event) => {
3056
+ app.use("/_agent-native/google/auth-url", defineEventHandler(async (event) => {
2915
3057
  if (!areGenericGoogleOAuthRoutesEnabled(app))
2916
3058
  return undefined;
2917
- if (getMethod(event) !== "GET") {
3059
+ const method = getMethod(event);
3060
+ if (method !== "GET" && method !== "POST") {
2918
3061
  setResponseStatus(event, 405);
2919
3062
  return { error: "Method not allowed" };
2920
3063
  }
@@ -2931,7 +3074,37 @@ async function mountBetterAuthRoutes(app, options) {
2931
3074
  const q = getQuery(event);
2932
3075
  const desktop = isElectronRequest(event) || q.desktop === "1" || q.desktop === "true";
2933
3076
  const mobile = q.mobile === "1" || q.mobile === "true";
2934
- const flowId = desktop ? q.flow_id || undefined : undefined;
3077
+ const flowId = desktop && typeof q.flow_id === "string"
3078
+ ? normalizeDesktopFlowId(q.flow_id)
3079
+ : undefined;
3080
+ if (method === "POST" && (!desktop || !flowId)) {
3081
+ setResponseStatus(event, 400);
3082
+ return { error: "Invalid desktop exchange challenge." };
3083
+ }
3084
+ const requestedVerifier = desktop
3085
+ ? getHeader(event, "x-agent-native-desktop-verifier")
3086
+ : undefined;
3087
+ let desktopVerifierHash;
3088
+ let desktopBrowserBindingHash;
3089
+ if (desktop && (q.flow_id !== undefined || q.verifier !== undefined)) {
3090
+ if (method !== "POST" ||
3091
+ !flowId ||
3092
+ !requestedVerifier ||
3093
+ q.verifier !== undefined ||
3094
+ q.redirect !== undefined) {
3095
+ setResponseStatus(event, 400);
3096
+ return { error: "Invalid desktop exchange challenge." };
3097
+ }
3098
+ try {
3099
+ desktopBrowserBindingHash =
3100
+ prepareDesktopOAuthBrowserBinding(event);
3101
+ desktopVerifierHash = await registerDesktopExchange(flowId, requestedVerifier, desktopBrowserBindingHash);
3102
+ }
3103
+ catch {
3104
+ setResponseStatus(event, 400);
3105
+ return { error: "Invalid desktop exchange challenge." };
3106
+ }
3107
+ }
2935
3108
  // Validate the caller's return param up front and only embed it
2936
3109
  // into the OAuth state when it normalises to a non-root path —
2937
3110
  // skip embedding "/" (the default fallback) so the state stays
@@ -2953,7 +3126,9 @@ async function mountBetterAuthRoutes(app, options) {
2953
3126
  addAccount: false,
2954
3127
  app: getOAuthStateAppId(),
2955
3128
  returnUrl,
2956
- flowId,
3129
+ flowId: flowId ?? undefined,
3130
+ desktopVerifierHash,
3131
+ desktopBrowserBindingHash,
2957
3132
  signupAttribution,
2958
3133
  signupAnonymousId,
2959
3134
  });
@@ -3009,7 +3184,7 @@ async function mountBetterAuthRoutes(app, options) {
3009
3184
  try {
3010
3185
  const query = getQuery(event);
3011
3186
  const code = query.code;
3012
- const { redirectUri, desktop, mobile, returnUrl, flowId, signupAttribution, signupAnonymousId, } = decodeOAuthState(query.state, getAppUrl(event, "/_agent-native/google/callback"));
3187
+ const { redirectUri, desktop, mobile, returnUrl, flowId, desktopVerifierHash, desktopBrowserBindingHash, signupAttribution, signupAnonymousId, } = decodeOAuthState(query.state, getAppUrl(event, "/_agent-native/google/callback"));
3013
3188
  callbackFlowId = flowId;
3014
3189
  callbackDesktop = desktop ?? false;
3015
3190
  callbackMobile = mobile ?? false;
@@ -3066,6 +3241,13 @@ async function mountBetterAuthRoutes(app, options) {
3066
3241
  });
3067
3242
  return oauthErrorPage(msg);
3068
3243
  }
3244
+ if (flowId) {
3245
+ if (!desktopVerifierHash ||
3246
+ !desktopBrowserBindingHash ||
3247
+ !matchesDesktopOAuthBrowserBinding(event, desktopBrowserBindingHash)) {
3248
+ throw new Error("Desktop OAuth browser binding is invalid.");
3249
+ }
3250
+ }
3069
3251
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
3070
3252
  method: "POST",
3071
3253
  headers: {
@@ -3140,7 +3322,10 @@ async function mountBetterAuthRoutes(app, options) {
3140
3322
  emailDomain: email.split("@")[1] || "",
3141
3323
  });
3142
3324
  if (flowId && sessionToken) {
3143
- setDesktopExchange(flowId, sessionToken, email);
3325
+ if (!desktopVerifierHash) {
3326
+ throw new Error("Missing desktop exchange challenge.");
3327
+ }
3328
+ await setDesktopExchange(flowId, sessionToken, email, desktopVerifierHash);
3144
3329
  logGoogleOAuthDebug(event, "callback-exchange-stored", {
3145
3330
  flowId,
3146
3331
  desktop,
@@ -3186,79 +3371,110 @@ async function mountBetterAuthRoutes(app, options) {
3186
3371
  setResponseStatus(event, 400);
3187
3372
  return { error: "Missing flow_id" };
3188
3373
  }
3189
- const verifier = normalizeDesktopFlowVerifier(getQuery(event).verifier);
3190
- let entry = _desktopExchanges.get(flowId);
3191
- if (!entry || entry.expiresAt < Date.now()) {
3192
- const challenge = await readDesktopMagicLinkChallengeFromDB(flowId);
3193
- if (challenge) {
3194
- if (!verifier ||
3195
- !matchesDesktopFlowVerifier(verifier, challenge.verifierHash)) {
3196
- setResponseStatus(event, 403);
3197
- return { error: "Invalid desktop exchange verifier." };
3198
- }
3199
- return { pending: true, flow: oauthDebugFlowId(flowId) };
3200
- }
3201
- // In-memory miss — fall back to the DB-persisted entry. This handles
3202
- // cross-instance routing (Cloudflare Workers, multi-region) where the
3203
- // OAuth callback and the polling request may hit different isolates.
3204
- const fromDb = await consumeDesktopExchangeFromDB(flowId);
3205
- if (!fromDb) {
3206
- // Don't log on the pending path — clients poll every second for up
3207
- // to 5 minutes, so logging here floods telemetry. The auth-url,
3208
- // callback-start, callback-session-created, exchange-success, and
3209
- // exchange-error breadcrumbs already cover every meaningful state
3210
- // transition.
3211
- return { pending: true, flow: oauthDebugFlowId(flowId) };
3374
+ if (query.verifier !== undefined) {
3375
+ setResponseStatus(event, 400);
3376
+ return {
3377
+ error: "Desktop exchange verifier must use a request header.",
3378
+ };
3379
+ }
3380
+ const verifier = normalizeDesktopFlowVerifier(getHeader(event, "x-agent-native-desktop-verifier"));
3381
+ const cached = _desktopExchanges.get(flowId);
3382
+ if (cached && cached.expiresAt < Date.now()) {
3383
+ _desktopExchanges.delete(flowId);
3384
+ }
3385
+ const current = _desktopExchanges.get(flowId);
3386
+ if (current && "challenge" in current) {
3387
+ if (!verifier ||
3388
+ !matchesDesktopFlowVerifier(verifier, current.verifierHash)) {
3389
+ setResponseStatus(event, 403);
3390
+ return { error: "Invalid desktop exchange verifier." };
3212
3391
  }
3213
- entry =
3214
- "error" in fromDb
3215
- ? { error: fromDb.error, expiresAt: Date.now() + 1 }
3216
- : "challenge" in fromDb
3217
- ? {
3218
- challenge: true,
3219
- verifierHash: fromDb.verifierHash,
3220
- expiresAt: Date.now() + 1,
3221
- }
3222
- : {
3223
- token: fromDb.token,
3224
- email: fromDb.email,
3225
- ...(fromDb.verifierHash
3226
- ? { verifierHash: fromDb.verifierHash }
3227
- : {}),
3228
- expiresAt: Date.now() + 1,
3229
- };
3230
- }
3231
- if ("challenge" in entry) {
3392
+ return { pending: true, flow: oauthDebugFlowId(flowId) };
3393
+ }
3394
+ if (current && "error" in current) {
3395
+ _desktopExchanges.delete(flowId);
3396
+ removeSession(`dex:${flowId}`).catch((err) => {
3397
+ console.warn("[auth] desktop-exchange DB cleanup failed:", describeDbError(err));
3398
+ });
3399
+ logGoogleOAuthDebug(event, "exchange-error", {
3400
+ flowId,
3401
+ message: current.error.message,
3402
+ code: current.error.code,
3403
+ });
3404
+ setResponseStatus(event, 400);
3405
+ return { error: current.error.message, ...current.error };
3406
+ }
3407
+ // The DB is authoritative for token exchanges. Read and validate before
3408
+ // the conditional delete so malformed rows remain available for
3409
+ // diagnosis and two instances cannot both redeem the same payload.
3410
+ const fromDb = await readDesktopExchangeFromDB(flowId);
3411
+ if (fromDb.status === "missing" || fromDb.status === "unavailable") {
3412
+ // Don't log on the pending path — clients poll every second for up
3413
+ // to 5 minutes, so logging here floods telemetry. The auth-url,
3414
+ // callback-start, callback-session-created, exchange-success, and
3415
+ // exchange-error breadcrumbs already cover every meaningful state
3416
+ // transition.
3417
+ return { pending: true, flow: oauthDebugFlowId(flowId) };
3418
+ }
3419
+ if (fromDb.status === "malformed") {
3420
+ setResponseStatus(event, 500);
3421
+ return {
3422
+ error: "Desktop exchange storage is invalid.",
3423
+ code: "exchange_storage_invalid",
3424
+ };
3425
+ }
3426
+ if ("challenge" in fromDb.entry) {
3232
3427
  if (!verifier ||
3233
- !matchesDesktopFlowVerifier(verifier, entry.verifierHash)) {
3428
+ !matchesDesktopFlowVerifier(verifier, fromDb.entry.verifierHash)) {
3234
3429
  setResponseStatus(event, 403);
3235
3430
  return { error: "Invalid desktop exchange verifier." };
3236
3431
  }
3237
3432
  return { pending: true, flow: oauthDebugFlowId(flowId) };
3238
3433
  }
3239
- if ("token" in entry && entry.verifierHash) {
3434
+ if ("token" in fromDb.entry &&
3435
+ (!fromDb.entry.verifierHash ||
3436
+ !verifier ||
3437
+ !matchesDesktopFlowVerifier(verifier, fromDb.entry.verifierHash))) {
3438
+ setResponseStatus(event, 403);
3439
+ return { error: "Invalid desktop exchange verifier." };
3440
+ }
3441
+ const consumed = await consumeDesktopExchangeFromDB(flowId);
3442
+ if (consumed.status === "missing" || consumed.status === "unavailable") {
3443
+ return { pending: true, flow: oauthDebugFlowId(flowId) };
3444
+ }
3445
+ if (consumed.status === "malformed") {
3446
+ setResponseStatus(event, 500);
3447
+ return {
3448
+ error: "Desktop exchange storage is invalid.",
3449
+ code: "exchange_storage_invalid",
3450
+ };
3451
+ }
3452
+ if ("challenge" in consumed.entry) {
3240
3453
  if (!verifier ||
3241
- !matchesDesktopFlowVerifier(verifier, entry.verifierHash)) {
3454
+ !matchesDesktopFlowVerifier(verifier, consumed.entry.verifierHash)) {
3242
3455
  setResponseStatus(event, 403);
3243
3456
  return { error: "Invalid desktop exchange verifier." };
3244
3457
  }
3458
+ return { pending: true, flow: oauthDebugFlowId(flowId) };
3245
3459
  }
3246
- _desktopExchanges.delete(flowId);
3247
- // Also wipe the DB-persisted entry so it cannot be replayed via the
3248
- // DB fallback path after in-memory consumption. Best-effort: a dropped
3249
- // Neon WebSocket rejects with a raw ErrorEvent, and a floating
3250
- // rejection here surfaces as an unhandled promise rejection.
3251
- removeSession(`dex:${flowId}`).catch((err) => {
3252
- console.warn("[auth] desktop-exchange DB cleanup failed:", describeDbError(err));
3253
- });
3254
- if ("error" in entry) {
3460
+ if ("error" in consumed.entry) {
3255
3461
  logGoogleOAuthDebug(event, "exchange-error", {
3256
3462
  flowId,
3257
- message: entry.error.message,
3258
- code: entry.error.code,
3463
+ message: consumed.entry.error.message,
3464
+ code: consumed.entry.error.code,
3259
3465
  });
3260
3466
  setResponseStatus(event, 400);
3261
- return { error: entry.error.message, ...entry.error };
3467
+ return {
3468
+ error: consumed.entry.error.message,
3469
+ ...consumed.entry.error,
3470
+ };
3471
+ }
3472
+ const entry = consumed.entry;
3473
+ if (!verifier ||
3474
+ !entry.verifierHash ||
3475
+ !matchesDesktopFlowVerifier(verifier, entry.verifierHash)) {
3476
+ setResponseStatus(event, 403);
3477
+ return { error: "Invalid desktop exchange verifier." };
3262
3478
  }
3263
3479
  // Make the exchange itself establish the app session. Older clients
3264
3480
  // still make a follow-up /auth/session?_session=... request, but the