@agent-native/core 0.151.1 → 0.152.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 (256) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/sharing/SKILL.md +1 -1
  3. package/corpus/templates/analytics/.agents/skills/turn-into-app/SKILL.md +24 -0
  4. package/corpus/templates/analytics/actions/bigquery-query.ts +21 -0
  5. package/corpus/templates/analytics/app/components/ConnectionTestStatus.tsx +65 -0
  6. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +3 -3
  7. package/corpus/templates/analytics/app/pages/DataSources.tsx +15 -37
  8. package/corpus/templates/analytics/app/root.tsx +0 -1
  9. package/corpus/templates/analytics/changelog/2026-08-11-analytics-custom-blocks-keep-loading-with-legacy-bigquery-action.md +6 -0
  10. package/corpus/templates/analytics/changelog/2026-08-11-analytics-routes-company-knowledge-and-slack-context-questio.md +6 -0
  11. package/corpus/templates/analytics/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  12. package/corpus/templates/analytics/public/manifest.json +0 -1
  13. package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +5 -0
  14. package/corpus/templates/analytics/server/plugins/agent-chat.ts +5 -0
  15. package/corpus/templates/assets/.agents/skills/sharing/SKILL.md +1 -1
  16. package/corpus/templates/assets/.agents/skills/turn-into-app/SKILL.md +24 -0
  17. package/corpus/templates/assets/app/root.tsx +0 -1
  18. package/corpus/templates/assets/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  19. package/corpus/templates/assets/public/manifest.json +0 -1
  20. package/corpus/templates/brain/.agents/skills/sharing/SKILL.md +1 -1
  21. package/corpus/templates/brain/.agents/skills/turn-into-app/SKILL.md +24 -0
  22. package/corpus/templates/brain/app/root.tsx +0 -1
  23. package/corpus/templates/brain/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  24. package/corpus/templates/brain/public/manifest.json +0 -1
  25. package/corpus/templates/calendar/.agents/skills/sharing/SKILL.md +1 -1
  26. package/corpus/templates/calendar/.agents/skills/turn-into-app/SKILL.md +24 -0
  27. package/corpus/templates/calendar/app/components/calendar/GoogleConnectBanner.tsx +0 -53
  28. package/corpus/templates/calendar/app/i18n-data.ts +0 -80
  29. package/corpus/templates/calendar/app/root.tsx +0 -1
  30. package/corpus/templates/calendar/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  31. package/corpus/templates/calendar/changelog/2026-08-11-google-calendar-connection-buttons-now-open-the-sign-in-flow.md +6 -0
  32. package/corpus/templates/calendar/public/manifest.json +0 -1
  33. package/corpus/templates/calendar/server/plugins/auth.ts +0 -10
  34. package/corpus/templates/chat/.agents/skills/sharing/SKILL.md +1 -1
  35. package/corpus/templates/chat/.agents/skills/turn-into-app/SKILL.md +24 -0
  36. package/corpus/templates/chat/app/root.tsx +0 -1
  37. package/corpus/templates/chat/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  38. package/corpus/templates/chat/public/manifest.json +0 -1
  39. package/corpus/templates/clips/.agents/skills/turn-into-app/SKILL.md +24 -0
  40. package/corpus/templates/clips/app/components/player/playback-comment-overlay.tsx +1 -1
  41. package/corpus/templates/clips/app/components/player/share-dialog.tsx +6 -0
  42. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +20 -3
  43. package/corpus/templates/clips/app/i18n/en-US.ts +4 -4
  44. package/corpus/templates/clips/app/root.tsx +0 -1
  45. package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +1 -3
  46. package/corpus/templates/clips/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-08-11-clips-desktop-settings-are-now-easier-to-scan-with-a-wider-t.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-08-11-google-calendar-connections-from-meetings-now-open-directly.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-08-11-recording-viewers-can-comment-and-react.md +6 -0
  50. package/corpus/templates/clips/changelog/2026-08-11-system-audio-recording-now-lives-inside-the-microphone-menu-.md +6 -0
  51. package/corpus/templates/clips/changelog/2026-08-11-timestamped-comments-stay-on-the-video-for-three-seconds-wh.md +6 -0
  52. package/corpus/templates/clips/desktop/src/app.tsx +773 -1001
  53. package/corpus/templates/clips/desktop/src/components/MediaDeviceRow.tsx +15 -27
  54. package/corpus/templates/clips/desktop/src/styles.css +364 -5
  55. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +1 -1
  56. package/corpus/templates/clips/public/manifest.json +0 -1
  57. package/corpus/templates/content/.agents/skills/sharing/SKILL.md +1 -1
  58. package/corpus/templates/content/.agents/skills/turn-into-app/SKILL.md +24 -0
  59. package/corpus/templates/content/AGENTS.md +3 -0
  60. package/corpus/templates/content/actions/resolve-content-landing.ts +120 -0
  61. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +9 -0
  62. package/corpus/templates/content/app/i18n-data.ts +58 -0
  63. package/corpus/templates/content/app/lib/content-landing.ts +22 -0
  64. package/corpus/templates/content/app/root.tsx +0 -1
  65. package/corpus/templates/content/app/routes/_app._index.tsx +36 -26
  66. package/corpus/templates/content/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  67. package/corpus/templates/content/changelog/2026-08-11-content-opens-your-last-page-on-return-and-gives-new-users-a.md +6 -0
  68. package/corpus/templates/content/docs/product/capabilities/content.workspace.session-restore.md +7 -1
  69. package/corpus/templates/content/parity/matrix.md +1 -0
  70. package/corpus/templates/content/parity/matrix.ts +22 -0
  71. package/corpus/templates/content/public/manifest.json +0 -1
  72. package/corpus/templates/content/shared/content-landing.ts +17 -0
  73. package/corpus/templates/crm/.agents/skills/sharing/SKILL.md +1 -1
  74. package/corpus/templates/crm/.agents/skills/turn-into-app/SKILL.md +24 -0
  75. package/corpus/templates/crm/app/root.tsx +0 -1
  76. package/corpus/templates/crm/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  77. package/corpus/templates/crm/public/manifest.json +0 -1
  78. package/corpus/templates/design/.agents/skills/sharing/SKILL.md +1 -1
  79. package/corpus/templates/design/.agents/skills/turn-into-app/SKILL.md +24 -0
  80. package/corpus/templates/design/app/i18n/en-US.ts +5 -0
  81. package/corpus/templates/design/app/root.tsx +0 -1
  82. package/corpus/templates/design/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  83. package/corpus/templates/design/changelog/2026-08-11-design-sharing-now-identifies-viewers-who-can-add-review-com.md +6 -0
  84. package/corpus/templates/design/public/manifest.json +0 -1
  85. package/corpus/templates/dispatch/.agents/skills/sharing/SKILL.md +1 -1
  86. package/corpus/templates/dispatch/.agents/skills/turn-into-app/SKILL.md +24 -0
  87. package/corpus/templates/dispatch/AGENTS.md +9 -0
  88. package/corpus/templates/dispatch/actions/list-dispatch-usage-metrics.ts +5 -19
  89. package/corpus/templates/dispatch/actions/view-screen.ts +10 -3
  90. package/corpus/templates/dispatch/app/root.tsx +0 -1
  91. package/corpus/templates/dispatch/app/routes/settings.tsx +13 -1
  92. package/corpus/templates/dispatch/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  93. package/corpus/templates/dispatch/changelog/2026-08-11-clicking-the-dispatch-logo-returns-to-the-overview-page.md +6 -0
  94. package/corpus/templates/dispatch/changelog/2026-08-11-dispatch-no-longer-links-to-itself-from-the-app-switcher.md +6 -0
  95. package/corpus/templates/dispatch/changelog/2026-08-11-dispatch-settings-now-link-directly-to-admin.md +6 -0
  96. package/corpus/templates/dispatch/changelog/2026-08-11-dispatch-shows-usage-trends-recent-prompts-and-an-agent-revi.md +6 -0
  97. package/corpus/templates/dispatch/changelog/2026-08-11-human-friendly-app-names.md +6 -0
  98. package/corpus/templates/dispatch/public/manifest.json +0 -1
  99. package/corpus/templates/dispatch/server/lib/identity-sso.ts +432 -158
  100. package/corpus/templates/dispatch/server/plugins/identity-sso.ts +204 -123
  101. package/corpus/templates/dispatch/server/plugins/setup-dispatch.ts +1 -0
  102. package/corpus/templates/factory/.agents/skills/sharing/SKILL.md +1 -1
  103. package/corpus/templates/factory/.agents/skills/turn-into-app/SKILL.md +24 -0
  104. package/corpus/templates/factory/app/root.tsx +0 -1
  105. package/corpus/templates/factory/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  106. package/corpus/templates/factory/public/manifest.json +0 -1
  107. package/corpus/templates/forms/.agents/skills/sharing/SKILL.md +1 -1
  108. package/corpus/templates/forms/.agents/skills/turn-into-app/SKILL.md +24 -0
  109. package/corpus/templates/forms/app/root.tsx +0 -1
  110. package/corpus/templates/forms/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  111. package/corpus/templates/forms/public/manifest.json +0 -1
  112. package/corpus/templates/macros/.agents/skills/sharing/SKILL.md +1 -1
  113. package/corpus/templates/macros/.agents/skills/turn-into-app/SKILL.md +24 -0
  114. package/corpus/templates/mail/.agents/skills/sharing/SKILL.md +1 -1
  115. package/corpus/templates/mail/.agents/skills/turn-into-app/SKILL.md +24 -0
  116. package/corpus/templates/mail/app/root.tsx +0 -1
  117. package/corpus/templates/mail/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  118. package/corpus/templates/mail/changelog/2026-08-11-google-sign-in-now-opens-the-gmail-connection-flow-directly.md +6 -0
  119. package/corpus/templates/mail/public/manifest.json +0 -1
  120. package/corpus/templates/mail/server/plugins/auth.ts +0 -10
  121. package/corpus/templates/plan/.agents/skills/sharing/SKILL.md +1 -1
  122. package/corpus/templates/plan/.agents/skills/turn-into-app/SKILL.md +24 -0
  123. package/corpus/templates/plan/app/i18n/en-US.ts +2 -0
  124. package/corpus/templates/plan/app/pages/PlansPage.tsx +6 -0
  125. package/corpus/templates/plan/app/root.tsx +0 -1
  126. package/corpus/templates/plan/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  127. package/corpus/templates/plan/changelog/2026-08-11-plan-sharing-now-identifies-viewers-who-can-add-comments.md +6 -0
  128. package/corpus/templates/plan/public/manifest.json +0 -1
  129. package/corpus/templates/slides/.agents/skills/sharing/SKILL.md +1 -1
  130. package/corpus/templates/slides/.agents/skills/slide-editing/SKILL.md +13 -0
  131. package/corpus/templates/slides/.agents/skills/turn-into-app/SKILL.md +24 -0
  132. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +6 -0
  133. package/corpus/templates/slides/app/i18n/en-US.ts +2 -0
  134. package/corpus/templates/slides/app/root.tsx +0 -1
  135. package/corpus/templates/slides/changelog/2026-08-10-asking-the-agent-for-several-slide-animations-at-once-now-ke.md +6 -0
  136. package/corpus/templates/slides/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  137. package/corpus/templates/slides/changelog/2026-08-11-share-dialog-uses-commenter-terminology.md +6 -0
  138. package/corpus/templates/slides/public/manifest.json +0 -1
  139. package/corpus/templates/tasks/.agents/skills/sharing/SKILL.md +1 -1
  140. package/corpus/templates/tasks/.agents/skills/turn-into-app/SKILL.md +24 -0
  141. package/corpus/templates/tasks/app/root.tsx +0 -1
  142. package/corpus/templates/tasks/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  143. package/corpus/templates/tasks/public/manifest.json +0 -1
  144. package/dist/agent/production-agent.js +1 -22
  145. package/dist/agent/run-manager.d.ts +7 -0
  146. package/dist/agent/run-manager.js +46 -3
  147. package/dist/agent/tool-approval-migrations.d.ts +40 -0
  148. package/dist/agent/tool-approval-migrations.js +51 -0
  149. package/dist/agent/tool-approval-store.js +5 -20
  150. package/dist/cli/templates-meta.js +2 -2
  151. package/dist/client/AgentPanel.js +10 -1
  152. package/dist/client/AssistantChat.d.ts +3 -0
  153. package/dist/client/AssistantChat.js +148 -140
  154. package/dist/client/agent-page/AgentTabsPage.js +2 -2
  155. package/dist/client/chat/attachment-adapters.js +8 -2
  156. package/dist/client/chat/tool-call-display.d.ts +1 -0
  157. package/dist/client/chat/tool-call-display.js +3 -1
  158. package/dist/client/chat-first.js +11 -5
  159. package/dist/client/hooks/index.d.ts +1 -1
  160. package/dist/client/hooks/index.js +1 -1
  161. package/dist/client/org/OrgSwitcher.d.ts +3 -1
  162. package/dist/client/org/OrgSwitcher.js +22 -16
  163. package/dist/client/org/WorkspaceNotice.js +3 -1
  164. package/dist/client/resources/index.d.ts +1 -1
  165. package/dist/client/resources/index.js +1 -1
  166. package/dist/client/resources/use-mcp-servers.d.ts +1 -0
  167. package/dist/client/resources/use-mcp-servers.js +14 -0
  168. package/dist/client/settings/AccountSettingsCard.js +8 -4
  169. package/dist/client/settings/SettingsPanel.js +46 -13
  170. package/dist/client/settings/SettingsTabsPage.js +8 -3
  171. package/dist/client/sharing/ShareButton.d.ts +7 -1
  172. package/dist/client/sharing/ShareButton.js +14 -7
  173. package/dist/client/sharing/ShareDialog.js +5 -1
  174. package/dist/client/sharing/useShareButtonController.d.ts +4 -0
  175. package/dist/client/sharing/useShareButtonController.js +15 -0
  176. package/dist/client/sharing/useShareDialogController.d.ts +7 -0
  177. package/dist/client/sharing/useShareDialogController.js +14 -0
  178. package/dist/client/sse-event-processor.js +37 -0
  179. package/dist/client/use-session.d.ts +2 -0
  180. package/dist/client/use-session.js +69 -5
  181. package/dist/collab/struct-routes.d.ts +1 -1
  182. package/dist/deploy/build.js +1 -1
  183. package/dist/file-upload/pre-upload-attachments.d.ts +3 -4
  184. package/dist/file-upload/pre-upload-attachments.js +65 -17
  185. package/dist/ingestion/index.d.ts +1 -0
  186. package/dist/ingestion/index.js +1 -0
  187. package/dist/ingestion/office.d.ts +3 -3
  188. package/dist/ingestion/office.js +24 -2
  189. package/dist/ingestion/spreadsheet.d.ts +41 -0
  190. package/dist/ingestion/spreadsheet.js +172 -0
  191. package/dist/integrations/adapters/slack.js +7 -3
  192. package/dist/localization/default-messages.d.ts +3 -0
  193. package/dist/localization/default-messages.js +3 -0
  194. package/dist/mcp/embed-app.js +20 -9
  195. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  196. package/dist/notifications/routes.d.ts +6 -6
  197. package/dist/oauth-tokens/migrations.d.ts +8 -0
  198. package/dist/oauth-tokens/migrations.js +36 -0
  199. package/dist/observability/routes.d.ts +5 -5
  200. package/dist/org/workspace-url.d.ts +8 -0
  201. package/dist/org/workspace-url.js +26 -2
  202. package/dist/progress/routes.d.ts +1 -1
  203. package/dist/secrets/routes.d.ts +3 -3
  204. package/dist/server/agent-chat-plugin.js +16 -8
  205. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  206. package/dist/server/auth.d.ts +0 -11
  207. package/dist/server/auth.js +37 -20
  208. package/dist/server/better-auth-migrations.js +23 -0
  209. package/dist/server/email-template.d.ts +5 -0
  210. package/dist/server/email-template.js +9 -0
  211. package/dist/server/embed-route.js +17 -5
  212. package/dist/server/identity-sso-store.d.ts +40 -66
  213. package/dist/server/identity-sso-store.js +187 -150
  214. package/dist/server/identity-sso.d.ts +17 -73
  215. package/dist/server/identity-sso.js +294 -297
  216. package/dist/server/onboarding-html.d.ts +0 -12
  217. package/dist/server/onboarding-html.js +46 -269
  218. package/dist/server/realtime-token.d.ts +1 -1
  219. package/dist/server/release-migrations.js +8 -0
  220. package/dist/server/transcribe-voice.d.ts +1 -1
  221. package/dist/shared/index.d.ts +1 -1
  222. package/dist/shared/index.js +1 -1
  223. package/dist/shared/workspace-app-id.d.ts +2 -0
  224. package/dist/shared/workspace-app-id.js +10 -0
  225. package/dist/sharing/actions/share-resource.d.ts +1 -0
  226. package/dist/sharing/actions/share-resource.js +20 -5
  227. package/dist/templates/chat/.agents/skills/sharing/SKILL.md +1 -1
  228. package/dist/templates/chat/.agents/skills/turn-into-app/SKILL.md +24 -0
  229. package/dist/templates/chat/app/root.tsx +0 -1
  230. package/dist/templates/chat/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  231. package/dist/templates/chat/public/manifest.json +0 -1
  232. package/dist/templates/default/.agents/skills/turn-into-app/SKILL.md +24 -0
  233. package/dist/templates/default/app/root.tsx +0 -1
  234. package/dist/templates/default/public/manifest.json +0 -1
  235. package/dist/templates/headless/.agents/skills/turn-into-app/SKILL.md +24 -0
  236. package/dist/templates/workspace-core/.agents/skills/authentication/SKILL.md +22 -11
  237. package/dist/templates/workspace-core/.agents/skills/sharing/SKILL.md +1 -1
  238. package/dist/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +24 -0
  239. package/docs/content/cross-app-sso.mdx +85 -27
  240. package/docs/content/template-mail.mdx +0 -6
  241. package/package.json +4 -3
  242. package/src/templates/chat/.agents/skills/sharing/SKILL.md +1 -1
  243. package/src/templates/chat/.agents/skills/turn-into-app/SKILL.md +24 -0
  244. package/src/templates/chat/app/root.tsx +0 -1
  245. package/src/templates/chat/changelog/2026-08-11-browser-no-longer-offers-desktop-install.md +6 -0
  246. package/src/templates/chat/public/manifest.json +0 -1
  247. package/src/templates/default/.agents/skills/turn-into-app/SKILL.md +24 -0
  248. package/src/templates/default/app/root.tsx +0 -1
  249. package/src/templates/default/public/manifest.json +0 -1
  250. package/src/templates/headless/.agents/skills/turn-into-app/SKILL.md +24 -0
  251. package/src/templates/workspace-core/.agents/skills/authentication/SKILL.md +22 -11
  252. package/src/templates/workspace-core/.agents/skills/sharing/SKILL.md +1 -1
  253. package/src/templates/workspace-core/.agents/skills/turn-into-app/SKILL.md +24 -0
  254. package/corpus/templates/calendar/changelog/2026-06-23-added-a-heads-up-explaining-google-s-app-not-verified-screen.md +0 -6
  255. package/corpus/templates/clips/app/components/meetings/google-oauth-identity-notice.tsx +0 -51
  256. package/corpus/templates/clips/changelog/2026-07-30-google-calendar-warning.md +0 -6
@@ -1,88 +1,62 @@
1
1
  /**
2
- * Framework-table store for the cross-app SSO ("Sign in with Agent-Native")
3
- * CLIENT side. Backs two pieces of the federated login round-trip:
2
+ * Framework-table store for the cross-app SSO client.
4
3
  *
5
- * - `identity_sso_state` — short-lived (10 min), single-use, crypto-random
6
- * CSRF `state` values. Minted at `/_agent-native/identity/login`,
7
- * consumed exactly once at `/_agent-native/identity/callback`. Carries an
8
- * optional same-origin `return` path so the user lands back where they
9
- * started after federated sign-in.
10
- * - `identity_sso_jti` — replayed-token guard. The hub-issued identity JWT
11
- * carries a random `jti`; the first callback that verifies a given `jti`
12
- * records it here, and any later callback that presents the same `jti`
13
- * is rejected. Best-effort: a DB blip never widens the trust boundary
14
- * (signature + exp + scope + single-use state are still enforced), it
15
- * only relaxes the extra replay gate.
4
+ * The current protocol uses a fresh flow-state table instead of extending the
5
+ * original `identity_sso_state` table. That keeps the migration additive for
6
+ * deployments that already have the merged PR's schema:
16
7
  *
17
- * Mirrors `mcp/connect-store.ts`: lazy `ensureTable()`, `getDbExec()`,
18
- * dialect-agnostic SQL via `intType()`, `isConnectionError()` swallow so a
19
- * transient Neon WS drop never 500s. `CREATE TABLE IF NOT EXISTS` only —
20
- * strictly additive, never DROP / ALTER (shared prod DB rule).
8
+ * - `identity_sso_flow_state` binds state to the exact app, client,
9
+ * authority, callback, and PKCE challenge. State is single-use.
10
+ * - `identity_sso_jti` remains a defence-in-depth replay guard for the
11
+ * server-to-server assertion. The assertion is never sent through the
12
+ * browser; the one-time authorization code is the only browser credential.
21
13
  *
22
- * Node-only (crypto), bundled alongside the other framework auth modules.
23
- */
24
- /**
25
- * Read + normalise `AGENT_NATIVE_IDENTITY_HUB_URL`. Returns `undefined`
26
- * (feature OFF) unless it is set to a syntactically valid http(s) URL. A
27
- * malformed value is treated as OFF rather than throwing, so a typo can
28
- * never brick an app's login — it just behaves as if SSO were unconfigured.
14
+ * Uses the same portable raw-SQL pattern as the other framework stores. DDL is
15
+ * additive and lazy, and PostgreSQL creation goes through the DDL guard.
29
16
  */
17
+ export declare const SSO_STATE_TTL_MS: number;
18
+ export declare const SSO_LOGIN_MAX = 60;
19
+ export declare const SSO_LOGIN_WINDOW_MS = 60000;
30
20
  export declare function getIdentityHubUrl(): string | undefined;
31
- /**
32
- * Whether the federated-SSO client is active. When false, NOTHING in the
33
- * SSO module has any effect: the route 404s, the guard bypass is inert, the
34
- * login button is not rendered. This is the single switch the
35
- * env-unset-no-op invariant is asserted against.
36
- */
37
21
  export declare function isIdentitySsoEnabled(): boolean;
38
22
  export declare function isDesktopSsoCanaryUserAgent(userAgent: string | undefined): boolean;
39
23
  export declare function isCanonicalAgentNativeAppOrigin(origin: string | undefined): boolean;
40
24
  export declare function isCanonicalAgentNativeAppRequest(host: string | undefined, forwardedProtocol: string | undefined): boolean;
41
25
  /**
42
- * The conditional "Sign in with Agent-Native" entry injected into the login
43
- * page — ONLY when the feature is enabled. Returns an empty string when
44
- * disabled so the login HTML is byte-for-byte identical to today's output
45
- * with the env unset (asserted by the env-unset-no-op regression test). Pure
46
- * string builder, no I/O — safe to call during HTML render. Lives in this
47
- * leaf module so `onboarding-html.ts` can import it without creating an
48
- * `auth.ts` ↔ `identity-sso.ts` import cycle.
26
+ * The conditional login entry is the only browser UI this feature adds. It
27
+ * stays byte-for-byte absent when the direct web federation env is unset.
49
28
  */
50
29
  export declare function identitySsoLoginButtonHtml(): string;
51
- /** CSRF state values are valid for 10 minutes. */
52
- export declare const SSO_STATE_TTL_MS: number;
53
- /**
54
- * Rate limit for `identity/login`: at most this many state rows may be
55
- * created within `SSO_LOGIN_WINDOW_MS`. The endpoint is reachable without a
56
- * session (it's the entry point), so keep a coarse global cap to stop table
57
- * flooding without per-IP plumbing.
58
- */
59
- export declare const SSO_LOGIN_MAX = 60;
60
- export declare const SSO_LOGIN_WINDOW_MS = 60000;
61
- /**
62
- * Mint a fresh crypto-random `state` value, persist it with an optional
63
- * same-origin return path, and return it. Rate-limited at creation: at most
64
- * `SSO_LOGIN_MAX` rows within `SSO_LOGIN_WINDOW_MS`. Throws `RATE_LIMITED`
65
- * when the cap is exceeded so the route can map it to a 429.
66
- */
67
- export declare function createSsoState(returnPath: string | null): Promise<string>;
30
+ export interface CreateSsoStateInput {
31
+ returnPath: string | null;
32
+ appId: string;
33
+ clientId: string;
34
+ redirectUri: string;
35
+ authority: string;
36
+ codeChallenge: string;
37
+ }
38
+ export interface SsoStateBinding {
39
+ appId: string;
40
+ clientId: string;
41
+ redirectUri: string;
42
+ authority: string;
43
+ codeChallenge: string;
44
+ }
68
45
  export interface SsoStateConsumeResult {
69
46
  ok: boolean;
70
47
  returnPath: string | null;
71
48
  }
49
+ /** Mint and persist a bound, crypto-random state value. */
50
+ export declare function createSsoState(input: CreateSsoStateInput): Promise<string>;
72
51
  /**
73
- * Atomically consume a `state` value. Returns `{ ok: true, returnPath }` only
74
- * when the state existed, had not expired, and had not been consumed before —
75
- * and this call is the one that transitioned it to consumed (single-use,
76
- * enforced via a conditional UPDATE so a double callback can't both pass).
77
- * Any other condition returns `{ ok: false }`.
52
+ * Atomically consume state only when every security binding still matches.
53
+ * A missing or mismatched binding is indistinguishable from an unknown state.
78
54
  */
79
- export declare function consumeSsoState(state: string): Promise<SsoStateConsumeResult>;
55
+ export declare function consumeSsoState(state: string, expected: SsoStateBinding): Promise<SsoStateConsumeResult>;
80
56
  /**
81
- * Returns true when the given identity-token `jti` has already been seen
82
- * (i.e. this is a replay). On the first sighting, records the `jti` and
83
- * returns false. Best-effort: a store/DB error returns `false` (not a
84
- * replay) so a transient Neon WS drop never blocks a legitimate first-time
85
- * sign-in — signature + exp + scope + single-use CSRF state remain the hard
86
- * gates; this only adds defence in depth against token replay.
57
+ * Strict replay defense for the server-to-server assertion. A database error
58
+ * fails closed here: code exchange already provides the primary single-use
59
+ * guarantee, and refusing a login is safer than accepting an unverifiable
60
+ * replay boundary.
87
61
  */
88
62
  export declare function isJtiReplayed(jti: string | undefined): Promise<boolean>;
@@ -1,28 +1,21 @@
1
1
  /**
2
- * Framework-table store for the cross-app SSO ("Sign in with Agent-Native")
3
- * CLIENT side. Backs two pieces of the federated login round-trip:
2
+ * Framework-table store for the cross-app SSO client.
4
3
  *
5
- * - `identity_sso_state` — short-lived (10 min), single-use, crypto-random
6
- * CSRF `state` values. Minted at `/_agent-native/identity/login`,
7
- * consumed exactly once at `/_agent-native/identity/callback`. Carries an
8
- * optional same-origin `return` path so the user lands back where they
9
- * started after federated sign-in.
10
- * - `identity_sso_jti` — replayed-token guard. The hub-issued identity JWT
11
- * carries a random `jti`; the first callback that verifies a given `jti`
12
- * records it here, and any later callback that presents the same `jti`
13
- * is rejected. Best-effort: a DB blip never widens the trust boundary
14
- * (signature + exp + scope + single-use state are still enforced), it
15
- * only relaxes the extra replay gate.
4
+ * The current protocol uses a fresh flow-state table instead of extending the
5
+ * original `identity_sso_state` table. That keeps the migration additive for
6
+ * deployments that already have the merged PR's schema:
16
7
  *
17
- * Mirrors `mcp/connect-store.ts`: lazy `ensureTable()`, `getDbExec()`,
18
- * dialect-agnostic SQL via `intType()`, `isConnectionError()` swallow so a
19
- * transient Neon WS drop never 500s. `CREATE TABLE IF NOT EXISTS` only —
20
- * strictly additive, never DROP / ALTER (shared prod DB rule).
8
+ * - `identity_sso_flow_state` binds state to the exact app, client,
9
+ * authority, callback, and PKCE challenge. State is single-use.
10
+ * - `identity_sso_jti` remains a defence-in-depth replay guard for the
11
+ * server-to-server assertion. The assertion is never sent through the
12
+ * browser; the one-time authorization code is the only browser credential.
21
13
  *
22
- * Node-only (crypto), bundled alongside the other framework auth modules.
14
+ * Uses the same portable raw-SQL pattern as the other framework stores. DDL is
15
+ * additive and lazy, and PostgreSQL creation goes through the DDL guard.
23
16
  */
24
17
  import { randomBytes } from "node:crypto";
25
- import { getDbExec, isConnectionError, intType, isPostgres, } from "../db/client.js";
18
+ import { getDbExec, intType, isConnectionError, isPostgres, } from "../db/client.js";
26
19
  import { ensureTableExists } from "../db/ddl-guard.js";
27
20
  let _initPromise;
28
21
  const DESKTOP_SSO_CANARY_USER_AGENT = /AgentNativeDesktopSsoCanary\//i;
@@ -44,39 +37,38 @@ const CANONICAL_IDENTITY_SSO_APP_ORIGINS = new Set([
44
37
  "https://slides.agent-native.com",
45
38
  "https://tasks.agent-native.com",
46
39
  ]);
40
+ export const SSO_STATE_TTL_MS = 10 * 60_000;
41
+ export const SSO_LOGIN_MAX = 60;
42
+ export const SSO_LOGIN_WINDOW_MS = 60_000;
43
+ const STATE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
44
+ const APP_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
45
+ const CLIENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
46
+ const CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/;
47
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
48
+ const LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
47
49
  // ---------------------------------------------------------------------------
48
- // Feature switch — the SINGLE source of truth for whether the federated-SSO
49
- // client is active. Lives here (a leaf module with no dependency on auth.ts)
50
- // so BOTH the auth guard and the route handler import the identical
51
- // validator and can never drift. Pure env read, no I/O — safe on the guard
52
- // hot path.
50
+ // Feature switch — this module is intentionally dependency-light because the
51
+ // auth guard and the route handler both import the same pure switch.
53
52
  // ---------------------------------------------------------------------------
54
- /**
55
- * Read + normalise `AGENT_NATIVE_IDENTITY_HUB_URL`. Returns `undefined`
56
- * (feature OFF) unless it is set to a syntactically valid http(s) URL. A
57
- * malformed value is treated as OFF rather than throwing, so a typo can
58
- * never brick an app's login — it just behaves as if SSO were unconfigured.
59
- */
60
53
  export function getIdentityHubUrl() {
61
54
  const raw = process.env.AGENT_NATIVE_IDENTITY_HUB_URL?.trim();
62
55
  if (!raw)
63
56
  return undefined;
64
57
  try {
65
58
  const u = new URL(raw);
66
- if (u.protocol !== "https:" && u.protocol !== "http:")
59
+ if (u.protocol !== "https:" &&
60
+ !(u.protocol === "http:" && LOCALHOST_HOSTS.has(u.hostname))) {
61
+ return undefined;
62
+ }
63
+ if (u.username || u.password || u.search || u.hash)
67
64
  return undefined;
68
65
  return `${u.protocol}//${u.host}${u.pathname}`.replace(/\/+$/, "");
69
66
  }
70
- catch {
67
+ catch (error) {
68
+ void error;
71
69
  return undefined;
72
70
  }
73
71
  }
74
- /**
75
- * Whether the federated-SSO client is active. When false, NOTHING in the
76
- * SSO module has any effect: the route 404s, the guard bypass is inert, the
77
- * login button is not rendered. This is the single switch the
78
- * env-unset-no-op invariant is asserted against.
79
- */
80
72
  export function isIdentitySsoEnabled() {
81
73
  return !!getIdentityHubUrl();
82
74
  }
@@ -96,24 +88,19 @@ export function isCanonicalAgentNativeAppOrigin(origin) {
96
88
  !parsed.hash &&
97
89
  CANONICAL_IDENTITY_SSO_APP_ORIGINS.has(parsed.origin));
98
90
  }
99
- catch {
91
+ catch (error) {
92
+ void error;
100
93
  return false;
101
94
  }
102
95
  }
103
96
  export function isCanonicalAgentNativeAppRequest(host, forwardedProtocol) {
104
- if (!host || forwardedProtocol !== "https") {
97
+ if (!host || forwardedProtocol !== "https")
105
98
  return false;
106
- }
107
99
  return isCanonicalAgentNativeAppOrigin(`https://${host}`);
108
100
  }
109
101
  /**
110
- * The conditional "Sign in with Agent-Native" entry injected into the login
111
- * page — ONLY when the feature is enabled. Returns an empty string when
112
- * disabled so the login HTML is byte-for-byte identical to today's output
113
- * with the env unset (asserted by the env-unset-no-op regression test). Pure
114
- * string builder, no I/O — safe to call during HTML render. Lives in this
115
- * leaf module so `onboarding-html.ts` can import it without creating an
116
- * `auth.ts` ↔ `identity-sso.ts` import cycle.
102
+ * The conditional login entry is the only browser UI this feature adds. It
103
+ * stays byte-for-byte absent when the direct web federation env is unset.
117
104
  */
118
105
  export function identitySsoLoginButtonHtml() {
119
106
  if (!isIdentitySsoEnabled())
@@ -126,24 +113,16 @@ export function identitySsoLoginButtonHtml() {
126
113
  `color:inherit;font:inherit;font-weight:600;text-decoration:none;` +
127
114
  `cursor:pointer">Sign in with Agent-Native</a>\n`);
128
115
  }
129
- /** CSRF state values are valid for 10 minutes. */
130
- export const SSO_STATE_TTL_MS = 10 * 60_000;
131
- /**
132
- * Rate limit for `identity/login`: at most this many state rows may be
133
- * created within `SSO_LOGIN_WINDOW_MS`. The endpoint is reachable without a
134
- * session (it's the entry point), so keep a coarse global cap to stop table
135
- * flooding without per-IP plumbing.
136
- */
137
- export const SSO_LOGIN_MAX = 60;
138
- export const SSO_LOGIN_WINDOW_MS = 60_000;
139
- // Build the CREATE SQL lazily (not at module scope) so intType() runs at
140
- // RUNTIME, not import time — a module-scope call breaks any consumer whose
141
- // db/client mock doesn't stub intType (e.g. db-admin specs).
142
- function buildIdentitySsoStateCreateSql() {
116
+ function buildIdentitySsoFlowStateCreateSql() {
143
117
  return `
144
- CREATE TABLE IF NOT EXISTS identity_sso_state (
118
+ CREATE TABLE IF NOT EXISTS identity_sso_flow_state (
145
119
  state TEXT PRIMARY KEY,
146
120
  return_path TEXT,
121
+ app_id TEXT NOT NULL,
122
+ client_id TEXT NOT NULL,
123
+ redirect_uri TEXT NOT NULL,
124
+ authority TEXT NOT NULL,
125
+ code_challenge TEXT NOT NULL,
147
126
  created_at ${intType()},
148
127
  expires_at ${intType()},
149
128
  consumed_at ${intType()}
@@ -161,94 +140,138 @@ function buildIdentitySsoJtiCreateSql() {
161
140
  async function ensureTable() {
162
141
  if (!_initPromise) {
163
142
  _initPromise = (async () => {
164
- const client = getDbExec();
165
- const identitySsoStateCreateSql = buildIdentitySsoStateCreateSql();
166
- const identitySsoJtiCreateSql = buildIdentitySsoJtiCreateSql();
167
- // Additive only. Never DROP / ALTER — this DB is shared across every
168
- // deploy context (preview/branch/prod) for hosted templates.
143
+ const flowStateSql = buildIdentitySsoFlowStateCreateSql();
144
+ const jtiSql = buildIdentitySsoJtiCreateSql();
169
145
  if (isPostgres()) {
170
- // PG guard: probe → guarded DDL → re-probe; skips lock on already-migrated path
171
- await ensureTableExists("identity_sso_state", identitySsoStateCreateSql);
172
- await ensureTableExists("identity_sso_jti", identitySsoJtiCreateSql);
146
+ await ensureTableExists("identity_sso_flow_state", flowStateSql);
147
+ await ensureTableExists("identity_sso_jti", jtiSql);
173
148
  return;
174
149
  }
175
- // SQLite (local dev): no lock problem — keep the original behaviour.
176
- await client.execute(identitySsoStateCreateSql);
177
- await client.execute(identitySsoJtiCreateSql);
178
- })().catch((err) => {
179
- // Don't cache a rejection — let the next caller retry a fresh init.
150
+ const client = getDbExec();
151
+ await client.execute(flowStateSql);
152
+ await client.execute(jtiSql);
153
+ })().catch((error) => {
180
154
  _initPromise = undefined;
181
- throw err;
155
+ throw error;
182
156
  });
183
157
  }
184
158
  return _initPromise;
185
159
  }
186
- function numOrNull(v) {
187
- if (v == null)
160
+ function numOrNull(value) {
161
+ if (value == null)
188
162
  return null;
189
- const n = Number(v);
190
- return Number.isFinite(n) ? n : null;
163
+ const number = Number(value);
164
+ return Number.isFinite(number) ? number : null;
191
165
  }
192
- // ---------------------------------------------------------------------------
193
- // CSRF state
194
- // ---------------------------------------------------------------------------
195
- /**
196
- * Mint a fresh crypto-random `state` value, persist it with an optional
197
- * same-origin return path, and return it. Rate-limited at creation: at most
198
- * `SSO_LOGIN_MAX` rows within `SSO_LOGIN_WINDOW_MS`. Throws `RATE_LIMITED`
199
- * when the cap is exceeded so the route can map it to a 429.
200
- */
201
- export async function createSsoState(returnPath) {
166
+ function stringOrNull(value) {
167
+ return typeof value === "string" && value.length > 0 ? value : null;
168
+ }
169
+ function affectedRows(result) {
170
+ return Number(result?.rowsAffected ?? result?.rowCount ?? result?.count ?? 0);
171
+ }
172
+ function isSafeStateInput(input) {
173
+ if (!input.appId ||
174
+ !input.clientId ||
175
+ !input.redirectUri ||
176
+ !input.authority ||
177
+ !input.codeChallenge) {
178
+ return false;
179
+ }
180
+ if (!APP_ID_PATTERN.test(input.appId))
181
+ return false;
182
+ if (!CLIENT_ID_PATTERN.test(input.clientId))
183
+ return false;
184
+ if (!CODE_CHALLENGE_PATTERN.test(input.codeChallenge))
185
+ return false;
186
+ if (CONTROL_CHARS.test(input.redirectUri) ||
187
+ CONTROL_CHARS.test(input.authority)) {
188
+ return false;
189
+ }
190
+ try {
191
+ const redirect = new URL(input.redirectUri);
192
+ const authority = new URL(input.authority);
193
+ if (redirect.username ||
194
+ redirect.password ||
195
+ authority.username ||
196
+ authority.password) {
197
+ return false;
198
+ }
199
+ const secureOrLoopback = (url) => url.protocol === "https:" ||
200
+ (url.protocol === "http:" && LOCALHOST_HOSTS.has(url.hostname));
201
+ if (!secureOrLoopback(redirect) || !secureOrLoopback(authority)) {
202
+ return false;
203
+ }
204
+ }
205
+ catch (error) {
206
+ void error;
207
+ return false;
208
+ }
209
+ return true;
210
+ }
211
+ /** Mint and persist a bound, crypto-random state value. */
212
+ export async function createSsoState(input) {
213
+ if (!isSafeStateInput(input))
214
+ throw new Error("INVALID_SSO_STATE");
202
215
  await ensureTable();
203
216
  const client = getDbExec();
204
217
  const now = Date.now();
205
218
  try {
206
219
  const { rows } = await client.execute({
207
- sql: `SELECT COUNT(*) AS n FROM identity_sso_state WHERE created_at > ?`,
220
+ sql: "SELECT COUNT(*) AS n FROM identity_sso_flow_state WHERE created_at > ?",
208
221
  args: [now - SSO_LOGIN_WINDOW_MS],
209
222
  });
210
- const n = Number(rows[0]?.n ?? rows[0]?.["COUNT(*)"] ?? 0);
211
- if (Number.isFinite(n) && n >= SSO_LOGIN_MAX) {
223
+ const count = Number(rows[0]?.n ?? rows[0]?.["COUNT(*)"] ?? 0);
224
+ if (Number.isFinite(count) && count >= SSO_LOGIN_MAX) {
212
225
  throw new Error("RATE_LIMITED");
213
226
  }
214
227
  }
215
- catch (err) {
216
- if (err?.message === "RATE_LIMITED")
217
- throw err;
218
- // A read failure must not block legitimate logins — single-use +
219
- // short-TTL state is the primary protection. Continue.
228
+ catch (error) {
229
+ if (error?.message === "RATE_LIMITED")
230
+ throw error;
231
+ // A rate-limit read failure does not widen the auth boundary. The state
232
+ // remains high entropy, bound, short-lived, and single-use.
220
233
  }
221
234
  const state = randomBytes(32).toString("base64url");
222
- const expiresAt = now + SSO_STATE_TTL_MS;
223
235
  await client.execute({
224
- sql: `INSERT INTO identity_sso_state (state, return_path, created_at, expires_at, consumed_at) VALUES (?, ?, ?, ?, ?)`,
225
- args: [state, returnPath ?? null, now, expiresAt, null],
236
+ sql: "INSERT INTO identity_sso_flow_state " +
237
+ "(state, return_path, app_id, client_id, redirect_uri, authority, code_challenge, created_at, expires_at, consumed_at) " +
238
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
239
+ args: [
240
+ state,
241
+ input.returnPath,
242
+ input.appId,
243
+ input.clientId,
244
+ input.redirectUri,
245
+ input.authority,
246
+ input.codeChallenge,
247
+ now,
248
+ now + SSO_STATE_TTL_MS,
249
+ null,
250
+ ],
226
251
  });
227
- // Fire-and-forget: prune fully-expired rows (consumed or abandoned). Without
228
- // this the table grows unbounded and the rate-limit COUNT(*) above slows down.
229
252
  void client
230
253
  .execute({
231
- sql: `DELETE FROM identity_sso_state WHERE expires_at < ?`,
254
+ sql: "DELETE FROM identity_sso_flow_state WHERE expires_at < ?",
232
255
  args: [now],
233
256
  })
234
257
  .catch(() => { });
235
258
  return state;
236
259
  }
237
260
  /**
238
- * Atomically consume a `state` value. Returns `{ ok: true, returnPath }` only
239
- * when the state existed, had not expired, and had not been consumed before —
240
- * and this call is the one that transitioned it to consumed (single-use,
241
- * enforced via a conditional UPDATE so a double callback can't both pass).
242
- * Any other condition returns `{ ok: false }`.
261
+ * Atomically consume state only when every security binding still matches.
262
+ * A missing or mismatched binding is indistinguishable from an unknown state.
243
263
  */
244
- export async function consumeSsoState(state) {
245
- if (!state)
264
+ export async function consumeSsoState(state, expected) {
265
+ if (!STATE_PATTERN.test(state) ||
266
+ !isSafeStateInput({ ...expected, returnPath: null })) {
246
267
  return { ok: false, returnPath: null };
268
+ }
247
269
  await ensureTable();
248
270
  const client = getDbExec();
249
271
  const now = Date.now();
250
272
  const { rows } = await client.execute({
251
- sql: `SELECT state, return_path, expires_at, consumed_at FROM identity_sso_state WHERE state = ?`,
273
+ sql: "SELECT return_path, app_id, client_id, redirect_uri, authority, code_challenge, expires_at, consumed_at " +
274
+ "FROM identity_sso_flow_state WHERE state = ?",
252
275
  args: [state],
253
276
  });
254
277
  if (rows.length === 0)
@@ -256,62 +279,76 @@ export async function consumeSsoState(state) {
256
279
  const row = rows[0];
257
280
  const expiresAt = numOrNull(row.expires_at ?? row.expiresAt);
258
281
  const consumedAt = numOrNull(row.consumed_at ?? row.consumedAt);
259
- if (consumedAt != null)
282
+ if (consumedAt != null || (expiresAt != null && expiresAt < now)) {
260
283
  return { ok: false, returnPath: null };
261
- if (expiresAt != null && expiresAt < now) {
284
+ }
285
+ const appId = stringOrNull(row.app_id ?? row.appId);
286
+ const clientId = stringOrNull(row.client_id ?? row.clientId);
287
+ const redirectUri = stringOrNull(row.redirect_uri ?? row.redirectUri);
288
+ const authority = stringOrNull(row.authority);
289
+ const codeChallenge = stringOrNull(row.code_challenge ?? row.codeChallenge);
290
+ if (appId !== expected.appId ||
291
+ clientId !== expected.clientId ||
292
+ redirectUri !== expected.redirectUri ||
293
+ authority !== expected.authority ||
294
+ codeChallenge !== expected.codeChallenge) {
262
295
  return { ok: false, returnPath: null };
263
296
  }
264
- // Single-use: only the caller that flips `consumed_at` from NULL wins.
265
297
  const result = await client.execute({
266
- sql: `UPDATE identity_sso_state SET consumed_at = ? WHERE state = ? AND consumed_at IS NULL`,
267
- args: [now, state],
298
+ sql: "UPDATE identity_sso_flow_state SET consumed_at = ? " +
299
+ "WHERE state = ? AND consumed_at IS NULL AND app_id = ? AND client_id = ? " +
300
+ "AND redirect_uri = ? AND authority = ? AND code_challenge = ?",
301
+ args: [
302
+ now,
303
+ state,
304
+ expected.appId,
305
+ expected.clientId,
306
+ expected.redirectUri,
307
+ expected.authority,
308
+ expected.codeChallenge,
309
+ ],
268
310
  });
269
- if (result.rowsAffected === 0) {
270
- // Lost the race to a concurrent callback — treat as already consumed.
311
+ if (affectedRows(result) !== 1)
271
312
  return { ok: false, returnPath: null };
272
- }
273
- const returnPath = (row.return_path ?? row.returnPath ?? null);
274
- return { ok: true, returnPath };
313
+ return {
314
+ ok: true,
315
+ returnPath: stringOrNull(row.return_path ?? row.returnPath),
316
+ };
275
317
  }
276
- // ---------------------------------------------------------------------------
277
- // Replay (jti) guard
278
- // ---------------------------------------------------------------------------
279
318
  /**
280
- * Returns true when the given identity-token `jti` has already been seen
281
- * (i.e. this is a replay). On the first sighting, records the `jti` and
282
- * returns false. Best-effort: a store/DB error returns `false` (not a
283
- * replay) so a transient Neon WS drop never blocks a legitimate first-time
284
- * sign-in — signature + exp + scope + single-use CSRF state remain the hard
285
- * gates; this only adds defence in depth against token replay.
319
+ * Strict replay defense for the server-to-server assertion. A database error
320
+ * fails closed here: code exchange already provides the primary single-use
321
+ * guarantee, and refusing a login is safer than accepting an unverifiable
322
+ * replay boundary.
286
323
  */
287
324
  export async function isJtiReplayed(jti) {
288
325
  if (!jti)
289
- return false;
326
+ return true;
290
327
  try {
291
328
  await ensureTable();
292
329
  const client = getDbExec();
330
+ const now = Date.now();
293
331
  await client.execute({
294
- sql: `INSERT INTO identity_sso_jti (jti, seen_at) VALUES (?, ?)`,
295
- args: [jti, Date.now()],
332
+ sql: "INSERT INTO identity_sso_jti (jti, seen_at) VALUES (?, ?)",
333
+ args: [jti, now],
296
334
  });
297
- // The INSERT completed without throwing → this jti was never seen → not a
298
- // replay. (A replay manifests as a PK-conflict *exception*, handled below.)
299
- // Don't key off rowsAffected: some drivers report 0 for a successful insert,
300
- // which would wrongly flag a legitimate first-time sign-in as a replay.
335
+ void client
336
+ .execute({
337
+ sql: "DELETE FROM identity_sso_jti WHERE seen_at < ?",
338
+ args: [now - SSO_STATE_TTL_MS],
339
+ })
340
+ .catch(() => { });
301
341
  return false;
302
342
  }
303
- catch (err) {
304
- // Primary-key conflict = the jti already exists = replay.
305
- const msg = String(err?.message ?? "").toLowerCase();
306
- if (msg.includes("unique") ||
307
- msg.includes("duplicate") ||
308
- msg.includes("constraint")) {
343
+ catch (error) {
344
+ const message = String(error?.message ?? "").toLowerCase();
345
+ if (message.includes("unique") ||
346
+ message.includes("duplicate") ||
347
+ message.includes("constraint")) {
309
348
  return true;
310
349
  }
311
- // Any other error (incl. connection blips): fail open — do not block a
312
- // legitimate first sign-in over a transient DB issue.
313
- if (isConnectionError(err))
314
- return false;
315
- return false;
350
+ if (isConnectionError(error))
351
+ return true;
352
+ return true;
316
353
  }
317
354
  }