@k2b/cloud 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/README.md +8 -0
  2. package/package.json +11 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +217 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +49 -18
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/heartbeat.ts +203 -14
  12. package/src/_internal/registry.ts +28 -1
  13. package/src/_internal/runtime-watcher.ts +6 -0
  14. package/src/_internal/setup-lock.ts +103 -0
  15. package/src/_internal/static-assets.ts +52 -3
  16. package/src/_internal/status-preserving-ssr.ts +2 -0
  17. package/src/access/PermissionEditor.tsx +2 -2
  18. package/src/account/EntitySearch.tsx +1 -1
  19. package/src/ai/action-review.ts +101 -0
  20. package/src/ai/background-review.ts +72 -0
  21. package/src/ai/capabilities.ts +29 -11
  22. package/src/ai/capability-execution.ts +76 -43
  23. package/src/ai/capability-result.ts +95 -0
  24. package/src/ai/chat/action-card.ts +464 -0
  25. package/src/ai/chat/action-messages.ts +129 -0
  26. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  27. package/src/ai/chat/action-question.fixtures.ts +733 -0
  28. package/src/ai/chat/action-question.tsx +680 -0
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/blocks.tsx +19 -25
  31. package/src/ai/chat/message-actions.tsx +6 -0
  32. package/src/ai/chat/tool-action-words.ts +31 -0
  33. package/src/ai/chat/tool-action.tsx +456 -0
  34. package/src/ai/chat/turn-view.tsx +10 -160
  35. package/src/ai/chat/view-block.tsx +15 -5
  36. package/src/ai/chat-task-contracts.ts +4 -1
  37. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  38. package/src/ai/chat-task-reasons.ts +1 -1
  39. package/src/ai/chat-tasks.ts +55 -25
  40. package/src/ai/client/controller.ts +59 -0
  41. package/src/ai/default-tools.ts +9 -1
  42. package/src/ai/executor.ts +198 -39
  43. package/src/ai/firecrawl-tools.ts +15 -2
  44. package/src/ai/index.ts +3 -0
  45. package/src/ai/migrate.ts +26 -2
  46. package/src/ai/open-questions.ts +172 -0
  47. package/src/ai/projects-routes.ts +3 -0
  48. package/src/ai/protocol.ts +14 -2
  49. package/src/ai/question-text.ts +8 -0
  50. package/src/ai/recent-resources.ts +240 -0
  51. package/src/ai/recent-sent-refs.ts +38 -0
  52. package/src/ai/routes.ts +195 -5
  53. package/src/ai/runtime.ts +1 -0
  54. package/src/ai/skills-routes.ts +3 -0
  55. package/src/ai/skills.ts +10 -1
  56. package/src/ai/store.ts +245 -24
  57. package/src/ai/testing.ts +36 -0
  58. package/src/ai/tool-audit.ts +8 -0
  59. package/src/ai/tool-sentences.ts +152 -34
  60. package/src/ai/tool-wording.ts +36 -8
  61. package/src/ai/tools.ts +8 -3
  62. package/src/ai/types.ts +43 -4
  63. package/src/api/accounts-entities.ts +2 -1
  64. package/src/api/admin-ai-projects.ts +2 -0
  65. package/src/api/admin-ai-skills.ts +2 -0
  66. package/src/api/admin-ai-usage.ts +2 -0
  67. package/src/api/admin-geoip.ts +80 -0
  68. package/src/api/admin-identity.ts +3 -0
  69. package/src/api/admin-lifecycle.ts +3 -0
  70. package/src/api/admin-outgoing-mail.ts +2 -0
  71. package/src/api/app-approval-actions.ts +31 -7
  72. package/src/api/app-approval.ts +46 -5
  73. package/src/api/approvals-loader.ts +23 -1
  74. package/src/api/approvals.ts +2 -0
  75. package/src/api/auth/schemas.ts +3 -0
  76. package/src/api/auth.ts +67 -12
  77. package/src/api/capabilities.ts +44 -11
  78. package/src/api/capability-streams.ts +12 -0
  79. package/src/api/help.ts +4 -28
  80. package/src/api/index.ts +6 -1
  81. package/src/api/lasting-access-messages.ts +17 -0
  82. package/src/api/mcp.ts +30 -3
  83. package/src/api/me-panel-consent.ts +71 -0
  84. package/src/api/me.ts +159 -36
  85. package/src/api/search.ts +3 -1
  86. package/src/api/sync-ops.ts +2 -0
  87. package/src/api/widgets.ts +3 -0
  88. package/src/approvals/Approvals.tsx +166 -262
  89. package/src/approvals/approval-card.ts +68 -0
  90. package/src/approvals/messages.ts +12 -24
  91. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  92. package/src/browser/app-approval.ts +54 -5
  93. package/src/browser/commands.ts +22 -6
  94. package/src/browser/composer-catalog.ts +45 -3
  95. package/src/browser/composer-insert.ts +10 -0
  96. package/src/browser/composer-open-messages.ts +17 -0
  97. package/src/browser/composer-open.ts +110 -0
  98. package/src/browser/discoverable-command.ts +6 -0
  99. package/src/browser/hints.ts +6 -0
  100. package/src/browser/live-connection.ts +26 -2
  101. package/src/browser/navigation-apps.ts +7 -0
  102. package/src/browser/panel-context-bridge.ts +30 -0
  103. package/src/browser/panel-context-receiver.ts +53 -0
  104. package/src/browser/panel-menu.tsx +56 -0
  105. package/src/browser/panel-messages.ts +11 -0
  106. package/src/browser/panel-shortcuts.ts +31 -0
  107. package/src/browser/panel-slot.ts +412 -10
  108. package/src/browser/panel.ts +37 -0
  109. package/src/browser/search-commands.ts +6 -3
  110. package/src/capabilities/client.ts +8 -2
  111. package/src/capabilities/reviews.ts +205 -0
  112. package/src/capabilities/server.ts +10 -3
  113. package/src/capabilities/testing.ts +47 -0
  114. package/src/capabilities/types.ts +2 -2
  115. package/src/cli/account.ts +13 -3
  116. package/src/cli/admin/geoip.ts +115 -0
  117. package/src/cli/admin/index.ts +3 -0
  118. package/src/contracts/app-approval.ts +123 -16
  119. package/src/contracts/approvals.ts +2 -2
  120. package/src/contracts/capabilities.ts +410 -18
  121. package/src/contracts/geoip.ts +42 -0
  122. package/src/contracts/index.ts +3 -0
  123. package/src/contracts/panel-context-plain.ts +99 -0
  124. package/src/contracts/panel-context.ts +93 -0
  125. package/src/contracts/seen-hints.ts +35 -0
  126. package/src/contracts/shared.ts +3 -0
  127. package/src/server/client-ip.ts +5 -0
  128. package/src/server/help-payload.ts +38 -0
  129. package/src/server/middleware/auth.ts +13 -1
  130. package/src/server/middleware/middleware.ts +9 -0
  131. package/src/server/middleware/no-transform.ts +19 -0
  132. package/src/server/middleware/rate-limit.ts +4 -6
  133. package/src/server/request-context/address.ts +45 -0
  134. package/src/server/request-context/geoip.ts +380 -0
  135. package/src/server/request-context/index.ts +33 -0
  136. package/src/server/request-context/user-agent.ts +95 -0
  137. package/src/services/accounts/app.ts +23 -3
  138. package/src/services/accounts/users.ts +12 -1
  139. package/src/services/app-approval.ts +382 -59
  140. package/src/services/approvals/device.ts +49 -31
  141. package/src/services/approvals/index.ts +7 -5
  142. package/src/services/approvals/list.ts +27 -6
  143. package/src/services/approvals/question.ts +68 -0
  144. package/src/services/audit/index.ts +1 -0
  145. package/src/services/auth-flows/ipa.ts +39 -2
  146. package/src/services/auth-flows/notification-sender.ts +3 -0
  147. package/src/services/auth-flows/password-reset.ts +32 -6
  148. package/src/services/geoip/index.ts +164 -0
  149. package/src/services/geoip/store.ts +30 -0
  150. package/src/services/geoip/upload.ts +150 -0
  151. package/src/services/identity/routes.ts +5 -1
  152. package/src/services/index.ts +13 -0
  153. package/src/services/known-browsers.ts +312 -0
  154. package/src/services/login-address-store.ts +51 -0
  155. package/src/services/login-context.ts +118 -0
  156. package/src/services/mandates/index.ts +3 -0
  157. package/src/services/mandates/policy.ts +15 -3
  158. package/src/services/notifications/email-frame.ts +1 -1
  159. package/src/services/oauth-tokens.ts +16 -2
  160. package/src/services/panel-context-consent.ts +58 -0
  161. package/src/services/pdf/markdown.ts +160 -2
  162. package/src/services/service-account-credentials.ts +21 -0
  163. package/src/services/session/index.ts +296 -41
  164. package/src/services/session/recent.ts +13 -0
  165. package/src/services/webauthn.ts +12 -0
  166. package/src/shared/email-html.ts +2 -0
  167. package/src/shared/help.ts +3 -0
  168. package/src/shared-text/cursor.ts +22 -0
  169. package/src/shared-text/documents.ts +142 -0
  170. package/src/shared-text/events.ts +43 -0
  171. package/src/shared-text/index.ts +12 -0
  172. package/src/shared-text/log.ts +48 -0
  173. package/src/shared-text/text-diff.ts +23 -0
  174. package/src/shared-text/types.ts +41 -0
  175. package/src/ssr/AdminLayout.tsx +2 -0
  176. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  177. package/src/ssr/CloudShell.island.tsx +10 -2
  178. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  179. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  180. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  181. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  182. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  183. package/src/ssr/HelpPanelView.island.tsx +27 -0
  184. package/src/ssr/Layout.tsx +25 -2
  185. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  186. package/src/ssr/LayoutHelp.tsx +222 -419
  187. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
  188. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  189. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  190. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  191. package/src/ssr/MobileNavigation.tsx +2 -2
  192. package/src/ssr/PanelLayout.tsx +9 -1
  193. package/src/ssr/PanelRuntime.island.tsx +7 -0
  194. package/src/ssr/PanelSlot.tsx +53 -3
  195. package/src/ssr/ShellRuntime.island.tsx +11 -2
  196. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  197. package/src/ssr/app-color.ts +14 -9
  198. package/src/ssr/app-navigation.ts +16 -3
  199. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  200. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  201. package/src/ssr/help-messages.ts +4 -0
  202. package/src/ssr/help.ts +21 -0
  203. package/src/ssr/hints.ts +25 -0
  204. package/src/ssr/index.ts +3 -0
  205. package/src/ssr/layout-context.ts +2 -0
  206. package/src/ssr/layout-help-article.server.ts +50 -0
  207. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  208. package/src/ssr/layout-help-panel-document.ts +67 -0
  209. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  210. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  211. package/src/ssr/layout-help-panel-modules.ts +7 -0
  212. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  213. package/src/ssr/layout-help-panel-search.tsx +69 -0
  214. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  215. package/src/ssr/layout-help-panel-state.ts +141 -0
  216. package/src/ssr/layout-help-panel-types.ts +19 -0
  217. package/src/ssr/layout-help-start.ts +33 -7
  218. package/src/ssr/layout-help-views.tsx +484 -0
  219. package/src/ssr/panel-consent.ts +29 -0
  220. package/src/ssr/panel-context.ts +10 -0
  221. package/src/ssr/panel-idle.ts +9 -0
  222. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  223. package/src/ssr/platform-messages.ts +18 -0
  224. package/src/ssr/shell-data.tsx +2 -1
  225. package/src/styles/approvals.css +6 -0
  226. package/src/styles/effects.css +213 -9
  227. package/src/styles/file-chooser.css +2 -1
  228. package/src/styles/global.css +49 -12
  229. package/src/styles/panel.css +47 -4
  230. package/src/styles/resource-search.css +6 -2
  231. package/src/styles/tokens.css +17 -11
  232. package/src/styles/utilities-buttons.css +3 -2
  233. package/src/styles/utilities-data.css +2 -1
  234. package/src/styles/utilities-feedback.css +2 -2
  235. package/src/styles/utilities-layout.css +3 -13
  236. package/src/styles/utilities-markdown-editor.css +6 -6
  237. package/src/styles/utilities-navigation.css +17 -22
@@ -5,6 +5,8 @@ import { HTTPException } from "hono/http-exception";
5
5
  import { env } from "../../config/env";
6
6
  import { PWA_AUTH_PATH, PWA_COOKIES } from "../../contracts/pwa";
7
7
  import type { User } from "../../contracts/shared";
8
+ import { getLocale } from "../../server/locale";
9
+ import { describeRequest } from "../../server/request-context";
8
10
  import { isAccountCategoryAllowed } from "../account-category-policy";
9
11
  import { isAccountExpired } from "../account-model";
10
12
  import { audit } from "../audit";
@@ -12,9 +14,23 @@ import { IDENTITY_CLOCK_TOLERANCE_SECONDS, IDENTITY_ROLLOUT_MARGIN_MS } from "..
12
14
  import { invalidateIdentitySignerCache, type PreparedIdentitySigner, prepareIdentitySigner } from "../identity/key-ring";
13
15
  import { getIdentityRuntimeConfig } from "../identity/runtime-config";
14
16
  import { type CloudSessionClaims, isSessionJwtCandidate, signSessionToken, verifySessionToken } from "../identity/session-token";
17
+ import {
18
+ announceNewBrowser,
19
+ type NewBrowserSignInNotice,
20
+ type NewBrowserSignInNotifier,
21
+ newBrowserCookie,
22
+ noticeFirstName,
23
+ readBrowserCookie,
24
+ rememberSignIn,
25
+ type SignInMethod,
26
+ setBrowserCookie,
27
+ } from "../known-browsers";
28
+ import { logger } from "../logging";
15
29
  import * as settings from "../settings";
16
30
  import { loadJwtSession, loadJwtSessionIdentity } from "./user";
17
31
 
32
+ const log = logger("auth:session");
33
+
18
34
  export type SessionKind = "web" | "app";
19
35
 
20
36
  export type SessionData = {
@@ -77,12 +93,13 @@ const verifyForRequest = (c: Context, token: string): Promise<CloudSessionClaims
77
93
  /** Moves the account's authentication epoch, which ends every session issued before it.
78
94
  * Request-time expiry uses only this: device operations check expiry themselves and an
79
95
  * administrator can lift it, so revoking devices would depend on which request came first. */
80
- const endSessions = async (db: SQL, userId: string): Promise<void> => {
81
- const [updated] = await db<Array<{ id: string }>>`
96
+ const endSessions = async (db: SQL, userId: string): Promise<number> => {
97
+ const [updated] = await db<Array<{ auth_epoch: string | number | bigint }>>`
82
98
  UPDATE auth.users SET auth_epoch = auth_epoch + 1
83
- WHERE id = ${userId}::uuid RETURNING id
99
+ WHERE id = ${userId}::uuid RETURNING auth_epoch
84
100
  `;
85
101
  if (!updated) throw new Error("Cannot revoke sessions for an unknown user");
102
+ return Number(updated.auth_epoch);
86
103
  };
87
104
 
88
105
  const authenticateVerified = async (credential: CloudSessionClaims): Promise<AuthenticatedSession | null> => {
@@ -144,12 +161,24 @@ const revokeToken = async (token: string, requestId?: string | null): Promise<vo
144
161
  }
145
162
  };
146
163
 
164
+ export class SessionEpochChangedError extends Error {}
165
+ /** The Cloud Login device that approved a sign-in was removed before the session was issued. */
166
+ export class SessionAppDeviceInactiveError extends Error {}
167
+
147
168
  export type SessionIssueOptions = {
169
+ authEpoch?: number;
148
170
  ttlSeconds: number;
171
+ /** Only web session.create supplies browser persistence, never mobile issuance. */
172
+ knownBrowser?: Pick<
173
+ Parameters<typeof rememberSignIn>[1],
174
+ "cookie" | "browser" | "os" | "country" | "city" | "method" | "device" | "locale" | "announce"
175
+ >;
149
176
  /** Pre-chosen family id, for callers that record it in the same transaction. */
150
177
  sid?: string;
151
178
  /** Binds the family to a paired phone of the mobile app; the device must be active. */
152
179
  pwaDeviceId?: string | null;
180
+ /** The active Cloud Login device that approved this web sign-in. */
181
+ appDeviceId?: string;
153
182
  /** Web sign-ins update `last_login_local`; app renewals track use per device instead. */
154
183
  recordLogin?: boolean;
155
184
  };
@@ -162,12 +191,14 @@ type PreparedFamily = {
162
191
  expiresAt: Date;
163
192
  signer: PreparedIdentitySigner;
164
193
  options: SessionIssueOptions;
194
+ notice: NewBrowserSignInNotice | null;
165
195
  };
166
196
 
167
197
  const SIGNER_INACTIVE = "Prepared Cloud session signer is no longer active";
168
198
  const isSignerInactive = (error: unknown) => error instanceof Error && error.message === SIGNER_INACTIVE;
169
199
 
170
- /** Inserts one family in `tx`. Locks the user row first, like every caller that locks more rows. */
200
+ /** Inserts one family in `tx`. Locks the user row first, like every caller that locks more rows; `NO KEY UPDATE`
201
+ * serializes with every other account lock but lets foreign-key checks of concurrent writes pass. */
171
202
  const insertFamily = async (
172
203
  tx: SQL,
173
204
  c: Context,
@@ -175,19 +206,22 @@ const insertFamily = async (
175
206
  signer: PreparedIdentitySigner,
176
207
  options: SessionIssueOptions,
177
208
  ): Promise<PreparedFamily> => {
178
- const issuedAt = new Date();
179
- const expiresAt = new Date(issuedAt.getTime() + options.ttlSeconds * 1_000);
180
209
  const sid = options.sid ?? crypto.randomUUID();
181
210
  const requestId = c.req.header("x-request-id")?.slice(0, 64) ?? null;
182
211
  const userAgent = c.req.header("user-agent")?.slice(0, 256) ?? null;
183
- const [user] = await tx<Array<{ auth_epoch: string | number | bigint; provider: "local" | "ipa"; profile: "guest" | "user" }>>`
184
- SELECT auth_epoch, provider, profile FROM auth.users WHERE id = ${userId}::uuid FOR UPDATE
212
+ const [user] = await tx<
213
+ Array<{ given_name: string | null; auth_epoch: string | number | bigint; provider: "local" | "ipa"; profile: "guest" | "user" }>
214
+ >`
215
+ SELECT given_name, auth_epoch, provider, profile FROM auth.users WHERE id = ${userId}::uuid FOR NO KEY UPDATE
185
216
  `;
186
217
  if (!user) throw new Error("Cannot create a session for an unknown user");
218
+ const issuedAt = new Date();
219
+ const expiresAt = new Date(issuedAt.getTime() + options.ttlSeconds * 1_000);
187
220
  if (!(await isAccountCategoryAllowed(user, tx)))
188
221
  throw new HTTPException(403, { message: "This account category is disabled. Contact an administrator." });
189
222
  const epoch = Number(user.auth_epoch);
190
223
  if (!Number.isSafeInteger(epoch) || epoch < 0) throw new Error("User auth_epoch is invalid");
224
+ if (options.authEpoch !== undefined && epoch !== options.authEpoch) throw new SessionEpochChangedError();
191
225
  if (options.pwaDeviceId) {
192
226
  // Device revocation locks the same user row first, so a revoked device never gets a new family.
193
227
  const [device] = await tx`
@@ -195,6 +229,14 @@ const insertFamily = async (
195
229
  `;
196
230
  if (!device) throw new Error("Cannot create an app session for an inactive device");
197
231
  }
232
+ if (options.appDeviceId) {
233
+ // A device that decides a terminal sign-in holds its row and then takes a key-share lock on this user row, so the
234
+ // user row above is held without blocking key-share locks. Removals that end the device's sign-ins hold the user
235
+ // row too, so they wait for this family.
236
+ const [device] = await tx`SELECT id FROM auth.app_devices
237
+ WHERE id=${options.appDeviceId}::uuid AND user_id=${userId}::uuid AND revoked_at IS NULL`;
238
+ if (!device) throw new SessionAppDeviceInactiveError("Cannot create a session for an inactive sign-in device");
239
+ }
198
240
  const [activeKey] = await tx<Array<{ kid: string }>>`
199
241
  UPDATE auth.signing_keys
200
242
  SET verify_until = GREATEST(verify_until, ${new Date(expiresAt.getTime() + IDENTITY_CLOCK_TOLERANCE_SECONDS * 1_000 + IDENTITY_ROLLOUT_MARGIN_MS)})
@@ -204,12 +246,15 @@ const insertFamily = async (
204
246
  if (!activeKey) throw new Error(SIGNER_INACTIVE);
205
247
  await tx`
206
248
  INSERT INTO auth.session_families (
207
- sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending, pwa_device_id
249
+ sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending, pwa_device_id, app_device_id
208
250
  ) VALUES (${sid}, ${userId}, ${epoch}, ${signer.kid}, ${issuedAt}, ${expiresAt}, ${requestId}, ${userAgent},
209
- NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid), ${options.pwaDeviceId ?? null})
251
+ NOT EXISTS (SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid), ${options.pwaDeviceId ?? null}, ${options.appDeviceId ?? null})
210
252
  `;
253
+ const notice = options.knownBrowser
254
+ ? await rememberSignIn(tx, { ...options.knownBrowser, userId, firstName: noticeFirstName(user.given_name), now: issuedAt })
255
+ : null;
211
256
  if (options.recordLogin !== false) await tx`UPDATE auth.users SET last_login_local = ${issuedAt} WHERE id = ${userId}::uuid`;
212
- return { userId, sid, authEpoch: epoch, issuedAt, expiresAt, signer, options };
257
+ return { userId, sid, authEpoch: epoch, issuedAt, expiresAt, signer, options, notice };
213
258
  };
214
259
 
215
260
  /** Signs a committed family. Null when the signer was revoked meanwhile; the family is revoked then. */
@@ -244,7 +289,13 @@ const signFamily = async (family: PreparedFamily): Promise<string | null> => {
244
289
  };
245
290
 
246
291
  /** One issuance attempt is retried once when the signing key changes underneath it. */
247
- const issueJwtSession = async (c: Context, userId: string, options: SessionIssueOptions, attempt = 0): Promise<string> => {
292
+ const issueJwtSession = async (
293
+ c: Context,
294
+ userId: string,
295
+ options: SessionIssueOptions,
296
+ attempt = 0,
297
+ committedNotice: NewBrowserSignInNotice | null = null,
298
+ ): Promise<{ token: string; expiresAt: Date; notice: NewBrowserSignInNotice | null }> => {
248
299
  const signer = await prepareIdentitySigner("session");
249
300
  let family: PreparedFamily;
250
301
  try {
@@ -252,13 +303,14 @@ const issueJwtSession = async (c: Context, userId: string, options: SessionIssue
252
303
  } catch (error) {
253
304
  if (attempt === 0 && isSignerInactive(error)) {
254
305
  invalidateIdentitySignerCache("session", signer.kid);
255
- return issueJwtSession(c, userId, options, 1);
306
+ return issueJwtSession(c, userId, options, 1, committedNotice);
256
307
  }
257
308
  throw error;
258
309
  }
259
310
  const token = await signFamily(family);
260
- if (token) return token;
261
- if (attempt === 0) return issueJwtSession(c, userId, { ...options, sid: undefined }, 1);
311
+ const notice = committedNotice ?? family.notice;
312
+ if (token) return { token, expiresAt: family.expiresAt, notice };
313
+ if (attempt === 0) return issueJwtSession(c, userId, { ...options, sid: undefined }, 1, notice);
262
314
  throw new Error("Cloud session signer changed repeatedly during issuance");
263
315
  };
264
316
 
@@ -281,7 +333,7 @@ const issueInTransaction = async <T>(
281
333
  value = await sql.begin((tx) =>
282
334
  work(tx, async (userId, options) => {
283
335
  if (family) throw new Error("Only one session family per transaction");
284
- family = await insertFamily(tx, c, userId, signer, options);
336
+ family = await insertFamily(tx, c, userId, signer, { ...options, knownBrowser: undefined });
285
337
  return family.sid;
286
338
  }),
287
339
  );
@@ -294,7 +346,177 @@ const issueInTransaction = async <T>(
294
346
  }
295
347
  if (!family) return { value, token: null };
296
348
  const token = await signFamily(family);
297
- return { value, token: token ?? (await issueJwtSession(c, family.userId, { ...family.options, sid: undefined }, 1)) };
349
+ return { value, token: token ?? (await issueJwtSession(c, family.userId, { ...family.options, sid: undefined }, 1)).token };
350
+ }
351
+ };
352
+
353
+ /** Revokes the account's active mobile app phones and records each phone. */
354
+ const revokePhones = async (
355
+ tx: SQL,
356
+ userId: string,
357
+ reason: "sign_out_everywhere" | "sign_out_others" | "password_change" | "password_reset",
358
+ ): Promise<void> => {
359
+ const phones = await tx<Array<{ id: string }>>`
360
+ UPDATE auth.pwa_devices SET revoked_at = now(), revocation_reason = ${reason}
361
+ WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
362
+ `;
363
+ for (const phone of phones)
364
+ await audit.record(
365
+ {
366
+ action: "auth.pwa.device.revoke",
367
+ outcome: "allowed",
368
+ target: { type: "pwa_device", id: phone.id },
369
+ metadata: { targetUserId: userId, provenance: "system", reason },
370
+ },
371
+ tx,
372
+ );
373
+ };
374
+
375
+ /** Copies a web family to a new sid, epoch and signer; sign-in time, expiry, origin and the Cloud Login device
376
+ * that approved it stay the same, so removing that device still ends the copy. */
377
+ const copyFamily = async (tx: SQL, from: string, to: { sid: string; authEpoch: number; kid: string }): Promise<void> => {
378
+ await tx`
379
+ INSERT INTO auth.session_families (
380
+ sid, user_id, auth_epoch, signing_kid, issued_at, expires_at, created_request_id, created_user_agent, legal_pending,
381
+ pwa_device_id, app_device_id
382
+ )
383
+ SELECT ${to.sid}::uuid, user_id, ${to.authEpoch}, ${to.kid}, issued_at, expires_at,
384
+ created_request_id, created_user_agent, legal_pending, pwa_device_id, app_device_id
385
+ FROM auth.session_families WHERE sid = ${from}::uuid
386
+ `;
387
+ };
388
+
389
+ /** Ends other sign-ins and moves the chosen browser to a new session id with its sign-in time and expiry.
390
+ * The epoch change ends the account's other browser sessions, its OAuth sign-ins and access tokens, and pending
391
+ * Cloud Login pairings and device changes; mobile app phones are revoked with an audit entry per phone. Unlike
392
+ * `revokeAllForUser`, Cloud Login devices stay paired. API keys, passkeys and mandates stay valid.
393
+ * `keep` is the browser sign-in the request authenticated, with the epoch it was authenticated at. The kept
394
+ * family is revoked and replaced, so every check by session id rejects old copies of its cookie, and a
395
+ * request that a concurrent change already ended keeps nothing.
396
+ * If preparing the signer fails or it stays inactive after one retry, ends all sign-ins without keeping a browser.
397
+ * If its signer changes after commit, retries once with a new family unless another sign-out intervened.
398
+ * `kept` is null when no browser was kept: no `keep`, its epoch is no longer current, the family was not
399
+ * a current active web session of the account, or signing it after the change failed. That browser must
400
+ * sign in again. */
401
+ const endOtherSignIns = async (
402
+ userId: string,
403
+ options: { keep?: { sid: string; authEpoch: number } | null; reason: "password_change" | "password_reset" | "sign_out_others" },
404
+ ): Promise<{ kept: { token: string; expiresAt: Date } | null }> => {
405
+ const extendSigner = async (tx: SQL, signer: PreparedIdentitySigner, expiresAt: Date): Promise<void> => {
406
+ const [activeKey] = await tx<Array<{ kid: string }>>`
407
+ UPDATE auth.signing_keys
408
+ SET verify_until = GREATEST(verify_until, ${new Date(expiresAt.getTime() + IDENTITY_CLOCK_TOLERANCE_SECONDS * 1_000 + IDENTITY_ROLLOUT_MARGIN_MS)})
409
+ WHERE kid = ${signer.kid} AND purpose = 'session' AND state = 'active' AND sign_until > now()
410
+ RETURNING kid
411
+ `;
412
+ if (!activeKey) throw new Error(SIGNER_INACTIVE);
413
+ };
414
+ let keep = options.keep ?? null;
415
+ for (let attempt = 0; ; attempt += 1) {
416
+ let signer: PreparedIdentitySigner | null = null;
417
+ if (keep) {
418
+ try {
419
+ signer = await prepareIdentitySigner("session");
420
+ } catch {
421
+ log.warn("Could not prepare session signer; ending all sign-ins", { userId });
422
+ keep = null;
423
+ }
424
+ }
425
+ let family: PreparedFamily | null;
426
+ try {
427
+ family = await sql.begin(async (tx): Promise<PreparedFamily | null> => {
428
+ const [user] = await tx<Array<{ auth_epoch: string | number | bigint }>>`
429
+ SELECT auth_epoch FROM auth.users WHERE id = ${userId}::uuid FOR NO KEY UPDATE
430
+ `;
431
+ if (!user) throw new Error("Cannot revoke sessions for an unknown user");
432
+ const [kept] =
433
+ keep && Number(user.auth_epoch) === keep.authEpoch
434
+ ? // Locking the key orders this keep before a concurrent emergency key revocation. It is the
435
+ // lock extendSigner takes for the same current key, so concurrent keeps queue instead of deadlocking.
436
+ await tx<Array<{ sid: string; issued_at: Date; expires_at: Date }>>`
437
+ SELECT sf.sid, sf.issued_at, sf.expires_at FROM auth.session_families sf
438
+ JOIN auth.signing_keys sk ON sk.kid = sf.signing_kid AND sk.state <> 'revoked'
439
+ WHERE sf.sid = ${keep.sid}::uuid AND sf.user_id = ${userId}::uuid
440
+ AND sf.pwa_device_id IS NULL AND sf.revoked_at IS NULL AND sf.expires_at > now()
441
+ AND sf.auth_epoch = ${user.auth_epoch}
442
+ FOR UPDATE OF sf FOR NO KEY UPDATE OF sk
443
+ `
444
+ : [];
445
+ const authEpoch = await endSessions(tx, userId);
446
+ let prepared: PreparedFamily | null = null;
447
+ if (kept && signer) {
448
+ const issuedAt = new Date(kept.issued_at);
449
+ const expiresAt = new Date(kept.expires_at);
450
+ await extendSigner(tx, signer, expiresAt);
451
+ const sid = crypto.randomUUID();
452
+ await copyFamily(tx, kept.sid, { sid, authEpoch, kid: signer.kid });
453
+ await tx`
454
+ UPDATE auth.session_families SET revoked_at = now(), revocation_reason = 'rotated'
455
+ WHERE sid = ${kept.sid}::uuid
456
+ `;
457
+ prepared = {
458
+ userId,
459
+ sid,
460
+ authEpoch,
461
+ issuedAt,
462
+ expiresAt,
463
+ signer,
464
+ options: { ttlSeconds: (expiresAt.getTime() - issuedAt.getTime()) / 1_000, recordLogin: false },
465
+ notice: null,
466
+ };
467
+ }
468
+ await revokePhones(tx, userId, options.reason);
469
+ return prepared;
470
+ });
471
+ } catch (error) {
472
+ if (signer && isSignerInactive(error)) {
473
+ if (attempt === 0) {
474
+ invalidateIdentitySignerCache("session", signer.kid);
475
+ continue;
476
+ }
477
+ if (attempt === 1) {
478
+ log.warn("Session signer stayed inactive; ending all sign-ins", { userId });
479
+ keep = null;
480
+ continue;
481
+ }
482
+ }
483
+ throw error;
484
+ }
485
+ if (!family) return { kept: null };
486
+ try {
487
+ const token = await signFamily(family);
488
+ if (token) return { kept: { token, expiresAt: family.expiresAt } };
489
+ const freshSigner = await prepareIdentitySigner("session");
490
+ const replacement = await sql.begin(async (tx): Promise<PreparedFamily | null> => {
491
+ const [user] = await tx<Array<{ auth_epoch: string | number | bigint }>>`
492
+ SELECT auth_epoch FROM auth.users WHERE id = ${userId}::uuid FOR NO KEY UPDATE
493
+ `;
494
+ if (!user || Number(user.auth_epoch) !== family.authEpoch) return null;
495
+ // Device removal can skip this already-revoked family, so the replacement must recheck its device.
496
+ const [revoked] = await tx<Array<{ sid: string }>>`
497
+ SELECT sf.sid FROM auth.session_families sf
498
+ WHERE sf.sid = ${family.sid}::uuid AND sf.user_id = ${userId}::uuid
499
+ AND sf.revocation_reason = 'signer_revoked_during_issuance'
500
+ AND (sf.app_device_id IS NULL OR EXISTS (
501
+ SELECT 1 FROM auth.app_devices d WHERE d.id = sf.app_device_id AND d.revoked_at IS NULL
502
+ )) FOR UPDATE
503
+ `;
504
+ if (!revoked) return null;
505
+ await extendSigner(tx, freshSigner, family.expiresAt);
506
+ const sid = crypto.randomUUID();
507
+ await copyFamily(tx, revoked.sid, { sid, authEpoch: family.authEpoch, kid: freshSigner.kid });
508
+ return { ...family, sid, signer: freshSigner };
509
+ });
510
+ if (!replacement) return { kept: null };
511
+ const replacementToken = await signFamily(replacement);
512
+ if (!replacementToken) throw new Error("Cloud session signer changed repeatedly during issuance");
513
+ return { kept: { token: replacementToken, expiresAt: replacement.expiresAt } };
514
+ } catch (error) {
515
+ log.warn("Could not keep browser signed in after ending other sign-ins", {
516
+ error: error instanceof Error ? error.message : String(error),
517
+ });
518
+ return { kept: null };
519
+ }
298
520
  }
299
521
  };
300
522
 
@@ -349,22 +571,67 @@ export const session = {
349
571
 
350
572
  getBearerToken: (c: Context): string | null => parseBearer(c.req.header("Authorization")),
351
573
 
352
- create: async (c: Context, userId: string): Promise<string> => {
574
+ create: async (
575
+ c: Context,
576
+ userId: string,
577
+ signIn: {
578
+ method: SignInMethod;
579
+ device?: string | null;
580
+ /** Null means this sign-in announces nothing, including during maintenance. */
581
+ notify: NewBrowserSignInNotifier | null;
582
+ /** Account epoch an earlier approval was bound to; issuance fails when sign-out everywhere moved it. */
583
+ authEpoch?: number;
584
+ /** The Cloud Login device that approved this sign-in; removing the device can end the session. */
585
+ appDeviceId?: string;
586
+ },
587
+ ): Promise<string> => {
353
588
  const expiryHours = await settings.get<number>("user.session.expiry_hours");
354
589
  if (!Number.isFinite(expiryHours) || expiryHours < 1) throw new Error("user.session.expiry_hours must be a positive number");
355
590
  const ttlSeconds = Math.floor(expiryHours * 60 * 60);
356
- const clientToken = await issueJwtSession(c, userId, { ttlSeconds });
591
+ const cookie = readBrowserCookie(c) ?? newBrowserCookie();
592
+ const { browser, os, country, city } = describeRequest(c);
593
+ const {
594
+ token: clientToken,
595
+ expiresAt,
596
+ notice,
597
+ } = await issueJwtSession(c, userId, {
598
+ ttlSeconds,
599
+ authEpoch: signIn.authEpoch,
600
+ appDeviceId: signIn.appDeviceId,
601
+ knownBrowser: {
602
+ cookie,
603
+ browser,
604
+ os,
605
+ country,
606
+ city,
607
+ method: signIn.method,
608
+ device: signIn.device ?? null,
609
+ locale: getLocale(c),
610
+ announce: signIn.notify !== null,
611
+ },
612
+ });
613
+ // Handoff precedes the response so the person signing in cannot turn the notice off first.
614
+ if (signIn.notify !== null && notice) await announceNewBrowser(signIn.notify, notice);
357
615
 
358
- setCookie(c, "session_token", clientToken, {
616
+ session.setWebCookie(c, clientToken, expiresAt);
617
+ setBrowserCookie(c, cookie);
618
+ return clientToken;
619
+ },
620
+
621
+ /** Sets the `session_token` cookie the way `create` does. Use it for the token that `endOtherSignIns` keeps:
622
+ * the max age runs to `expiresAt`, so that browser keeps its original expiry. */
623
+ setWebCookie: (c: Context, token: string, expiresAt: Date): void => {
624
+ setCookie(c, "session_token", token, {
359
625
  httpOnly: true,
360
626
  secure: !env.IS_DEVELOPMENT,
361
627
  sameSite: "Lax",
362
- maxAge: ttlSeconds,
628
+ maxAge: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1_000)),
363
629
  path: "/",
364
630
  });
365
- return clientToken;
366
631
  },
367
632
 
633
+ endOtherSignIns,
634
+
368
635
  revoke: revokeToken,
369
636
 
370
637
  /** Signs out the web session (or an explicit bearer session). The mobile app's session is unaffected. */
@@ -376,25 +643,12 @@ export const session = {
376
643
 
377
644
  issueInTransaction,
378
645
 
379
- /** Signs the account out everywhere: every session, every paired sign-in device and every paired phone, audited per device. */
380
- revokeAllForUser: async (userId: string): Promise<void> => {
381
- await sql.begin(async (tx) => {
382
- await endSessions(tx, userId);
383
- // The epoch change already ends the phones; revoking them keeps lists and the audit log truthful.
384
- const phones = await tx<Array<{ id: string }>>`
385
- UPDATE auth.pwa_devices SET revoked_at = now(), revocation_reason = 'sign_out_everywhere'
386
- WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
387
- `;
388
- for (const phone of phones)
389
- await audit.record(
390
- {
391
- action: "auth.pwa.device.revoke",
392
- outcome: "allowed",
393
- target: { type: "pwa_device", id: phone.id },
394
- metadata: { targetUserId: userId, provenance: "system", reason: "sign_out_everywhere" },
395
- },
396
- tx,
397
- );
646
+ /** Signs the account out everywhere: every session, every paired sign-in device and every paired phone, audited per device.
647
+ * Returns the new authentication epoch so a following sign-in can stay bound to this sign-out. */
648
+ revokeAllForUser: async (userId: string): Promise<number> => {
649
+ return sql.begin(async (tx) => {
650
+ const authEpoch = await endSessions(tx, userId);
651
+ await revokePhones(tx, userId, "sign_out_everywhere");
398
652
  // A paired device approves new sign-ins on its own, so a lost one would outlive the epoch change.
399
653
  const devices = await tx<Array<{ id: string }>>`
400
654
  UPDATE auth.app_devices SET revoked_at = now() WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
@@ -409,6 +663,7 @@ export const session = {
409
663
  },
410
664
  tx,
411
665
  );
666
+ return authEpoch;
412
667
  });
413
668
  },
414
669
 
@@ -11,6 +11,15 @@ export type EligibleAccount = {
11
11
  account_expires: Date | null;
12
12
  };
13
13
 
14
+ /** New personal access requires a browser sign-in from the last ten minutes. */
15
+ export const LASTING_ACCESS_SIGN_IN_SECONDS = 600;
16
+
17
+ export class ConfirmedBrowserSignInError extends Error {
18
+ constructor(readonly code: "REAUTHENTICATE" | "FORBIDDEN") {
19
+ super(code === "REAUTHENTICATE" ? "Sign in again to confirm it is you." : "This account cannot create new access.");
20
+ }
21
+ }
22
+
14
23
  type Reject = (code: "REAUTHENTICATE" | "FORBIDDEN") => never;
15
24
 
16
25
  /** The account exists, has not expired and its category is enabled. */
@@ -40,3 +49,7 @@ export const requireRecentWebSession = async (
40
49
  if (!row) return reject("REAUTHENTICATE");
41
50
  return requireEligibleAccount(tx, actor.userId, reject);
42
51
  };
52
+
53
+ /** Checks the browser session inside the transaction that creates personal access. */
54
+ export const requireConfirmedBrowserSignIn = (tx: SQL, signIn: { userId: string; sid: string }, reject: Reject): Promise<EligibleAccount> =>
55
+ requireRecentWebSession(tx, signIn, LASTING_ACCESS_SIGN_IN_SECONDS, reject);
@@ -16,6 +16,7 @@ import { accounts } from "./accounts";
16
16
  import { audit } from "./audit";
17
17
  import { logger } from "./logging";
18
18
  import { isUniqueViolation, toPgTextArray } from "./postgres";
19
+ import { ConfirmedBrowserSignInError, requireConfirmedBrowserSignIn } from "./session/recent";
19
20
  import { coreSettings } from "./settings/api";
20
21
 
21
22
  const CHALLENGE_TTL_SECONDS = 300;
@@ -185,6 +186,7 @@ export const beginRegistration = async (params: { user: User }): Promise<Result<
185
186
 
186
187
  export const finishRegistration = async (params: {
187
188
  user: User;
189
+ browserSignIn: { userId: string; sid: string };
188
190
  name: string;
189
191
  response: RegistrationResponseJSON;
190
192
  }): Promise<Result<WebAuthnPasskey>> => {
@@ -221,6 +223,16 @@ export const finishRegistration = async (params: {
221
223
 
222
224
  try {
223
225
  return sql.begin(async (tx) => {
226
+ if (params.browserSignIn.userId !== params.user.id) return fail(err.forbidden("The browser sign-in belongs to another account."));
227
+ try {
228
+ await requireConfirmedBrowserSignIn(tx, params.browserSignIn, (code) => {
229
+ throw new ConfirmedBrowserSignInError(code);
230
+ });
231
+ } catch (error) {
232
+ if (error instanceof ConfirmedBrowserSignInError) return fail({ code: error.code, status: 403, message: error.message });
233
+ throw error;
234
+ }
235
+
224
236
  const [row] = await tx<DbPasskeyRow[]>`
225
237
  INSERT INTO auth.webauthn_credentials (
226
238
  user_id,
@@ -10,6 +10,7 @@ export const EMAIL_HTML_TAGS = [
10
10
  "h1",
11
11
  "h2",
12
12
  "h3",
13
+ "hr",
13
14
  "li",
14
15
  "ol",
15
16
  "p",
@@ -33,6 +34,7 @@ export const EMAIL_HTML_ALLOWED_ATTRIBUTES: Record<string, string[]> = {
33
34
  h1: ["style"],
34
35
  h2: ["style"],
35
36
  h3: ["style"],
37
+ hr: ["style"],
36
38
  li: ["style"],
37
39
  ol: ["style"],
38
40
  p: ["style"],
@@ -1,5 +1,8 @@
1
1
  import { localeFallbackChain } from "./locale";
2
2
 
3
+ /** Maximum number of page-specific Help articles shared with the panel. */
4
+ export const HELP_VIEW_LIMIT = 3;
5
+
3
6
  export const HELP_KINDS = ["start", "task", "reference", "troubleshoot"] as const;
4
7
  export type HelpKind = (typeof HELP_KINDS)[number];
5
8
  /** The glossary's access levels; Help names only these, never app-specific permissions. */
@@ -0,0 +1,22 @@
1
+ const STREAM_CURSOR_REGEX = /^s6t\.[A-Za-z0-9_-]+\.\d+$/;
2
+
3
+ export const parseStreamCursor = (cursor: string | null | undefined): { resource: string; seq: number } | null => {
4
+ if (!cursor || !STREAM_CURSOR_REGEX.test(cursor)) return null;
5
+ const [, resource, sequence] = cursor.split(".");
6
+ const seq = Number(sequence);
7
+ if (!resource || !Number.isSafeInteger(seq) || seq < 0) return null;
8
+ return { resource, seq };
9
+ };
10
+
11
+ export const compareStreamCursor = (left: string, right: string): number => {
12
+ const l = parseStreamCursor(left);
13
+ const r = parseStreamCursor(right);
14
+ if (!l || !r || l.resource !== r.resource) throw new Error(`Invalid stream cursor comparison: "${left}" vs "${right}"`);
15
+ return l.seq - r.seq;
16
+ };
17
+
18
+ export const maxStreamCursor = (a: string | null, b: string | null): string | null => {
19
+ if (!a) return b;
20
+ if (!b) return a;
21
+ return compareStreamCursor(a, b) >= 0 ? a : b;
22
+ };