@k2b/cloud 0.40.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 (75) hide show
  1. package/package.json +6 -3
  2. package/src/_internal/capability-sentences.ts +2 -1
  3. package/src/_internal/define-app.ts +13 -3
  4. package/src/_internal/heartbeat.ts +203 -14
  5. package/src/_internal/registry.ts +28 -1
  6. package/src/_internal/setup-lock.ts +103 -0
  7. package/src/ai/chat/action-card.ts +69 -3
  8. package/src/ai/chat/action-question.tsx +12 -1
  9. package/src/ai/chat/action-text.ts +288 -0
  10. package/src/ai/chat/tool-action-words.ts +31 -0
  11. package/src/ai/chat/tool-action.tsx +1 -26
  12. package/src/ai/executor.ts +10 -2
  13. package/src/ai/firecrawl-tools.ts +15 -2
  14. package/src/ai/index.ts +1 -0
  15. package/src/ai/migrate.ts +15 -0
  16. package/src/ai/open-questions.ts +172 -0
  17. package/src/ai/question-text.ts +8 -0
  18. package/src/ai/routes.ts +51 -5
  19. package/src/ai/store.ts +18 -0
  20. package/src/ai/testing.ts +36 -0
  21. package/src/ai/tool-sentences.ts +152 -34
  22. package/src/ai/tool-wording.ts +12 -6
  23. package/src/ai/tools.ts +8 -3
  24. package/src/ai/types.ts +16 -2
  25. package/src/api/app-approval-actions.ts +31 -7
  26. package/src/api/auth/schemas.ts +3 -0
  27. package/src/api/auth.ts +54 -18
  28. package/src/api/mcp.ts +27 -3
  29. package/src/api/me.ts +79 -3
  30. package/src/approvals/Approvals.tsx +25 -63
  31. package/src/approvals/approval-card.ts +68 -0
  32. package/src/contracts/app-approval.ts +3 -3
  33. package/src/contracts/capabilities.ts +1 -1
  34. package/src/contracts/shared.ts +3 -0
  35. package/src/server/middleware/auth.ts +4 -2
  36. package/src/services/accounts/app.ts +23 -3
  37. package/src/services/accounts/users.ts +12 -1
  38. package/src/services/approvals/device.ts +49 -33
  39. package/src/services/approvals/index.ts +1 -0
  40. package/src/services/approvals/list.ts +1 -1
  41. package/src/services/approvals/question.ts +68 -0
  42. package/src/services/audit/index.ts +1 -0
  43. package/src/services/auth-flows/ipa.ts +39 -2
  44. package/src/services/auth-flows/password-reset.ts +32 -6
  45. package/src/services/index.ts +2 -0
  46. package/src/services/pdf/markdown.ts +160 -2
  47. package/src/services/session/index.ts +203 -29
  48. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  49. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  50. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  51. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  52. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  53. package/src/ssr/HelpPanelView.island.tsx +27 -0
  54. package/src/ssr/LayoutHelp.tsx +182 -540
  55. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  56. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  57. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  58. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  59. package/src/ssr/help.ts +4 -3
  60. package/src/ssr/index.ts +1 -0
  61. package/src/ssr/islands/index.ts +3 -4
  62. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  63. package/src/ssr/layout-help-panel-document.ts +67 -0
  64. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  65. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  66. package/src/ssr/layout-help-panel-modules.ts +7 -0
  67. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  68. package/src/ssr/layout-help-panel-search.tsx +69 -0
  69. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  70. package/src/ssr/layout-help-panel-state.ts +141 -0
  71. package/src/ssr/layout-help-panel-types.ts +19 -0
  72. package/src/ssr/layout-help-views.tsx +484 -0
  73. package/src/ssr/panel-idle.ts +9 -0
  74. package/src/styles/approvals.css +6 -0
  75. package/src/styles/effects.css +15 -0
@@ -1,4 +1,5 @@
1
1
  import { markdownInfoBlocks, markdownLinkReference, renderMarkdownLink } from "@k2b/ui";
2
+ import { decodeHTML } from "entities";
2
3
  import { Marked, Renderer, type Tokens } from "marked";
3
4
  import postcss from "postcss";
4
5
  import {
@@ -165,6 +166,161 @@ a.k2b-reference { padding: .05em .3em; border-radius: .3em; background: #eeeff2;
165
166
  .k2b-reference__icon, .k2b-text-link__external { display: none; }
166
167
  `;
167
168
 
169
+ /**
170
+ * Break-anywhere lowers table column width to one character, splitting even
171
+ * short numbers. Cells keep words whole; long runs get explicit break points.
172
+ */
173
+ const TABLE_CELL_PRINT_CSS = `
174
+ th, td { overflow-wrap: break-word; word-break: normal; }
175
+ `;
176
+
177
+ // Up to 20 characters covers nearly every real word, including German
178
+ // compounds and IBAN-like values below that length. Longer URLs, code, hashes
179
+ // or paths need a 10-character step: even nine columns fit the document
180
+ // preset's narrowest row (about 90 table characters). A 20-character step
181
+ // clipped six columns of long inline code that fitted before this protection.
182
+ const MAX_WHOLE_CELL_RUN = 20;
183
+ const CELL_RUN_BREAK_STEP = 10;
184
+
185
+ // References (including semicolonless ones) and literal code points are raw
186
+ // pieces. Decode for whitespace and grapheme boundaries, but preserve spelling.
187
+ // Limit names to 32 characters, the longest HTML reference name.
188
+ const CHARACTER_REFERENCE = /&(?:#[xX][0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]{0,31});?/gu;
189
+ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: "grapheme" });
190
+
191
+ const runUnits = (pieces: { raw: string; decoded: string }[]): string[] => {
192
+ const decoded = pieces.map((piece) => piece.decoded).join("");
193
+ const ends = new Set(Array.from(GRAPHEMES.segment(decoded), ({ index, segment }) => index + segment.length));
194
+ const units: string[] = [];
195
+ let at = 0;
196
+ let unit = "";
197
+ // A boundary must end both a grapheme and a raw piece, even if it decodes
198
+ // to several graphemes (such as &ampx or an unknown reference-like name).
199
+ for (const piece of pieces) {
200
+ unit += piece.raw;
201
+ at += piece.decoded.length;
202
+ if (ends.has(at)) {
203
+ units.push(unit);
204
+ unit = "";
205
+ }
206
+ }
207
+ return units;
208
+ };
209
+
210
+ // Inline formatting does not end a run: `aaa**bbb**ccc` prints one unbroken
211
+ // word. Any other element, such as <br>, <wbr>, an image, a span, or a
212
+ // block, starts a new run, so a short value beside it never breaks.
213
+ const INLINE_FORMATTING = new Set([
214
+ "a",
215
+ "b",
216
+ "code",
217
+ "del",
218
+ "em",
219
+ "i",
220
+ "ins",
221
+ "kbd",
222
+ "mark",
223
+ "s",
224
+ "samp",
225
+ "small",
226
+ "strong",
227
+ "sub",
228
+ "sup",
229
+ "u",
230
+ "var",
231
+ ]);
232
+
233
+ // Graphics and formulas keep their own layout. In raw-text and RCDATA elements
234
+ // a <wbr> would be literal text, not markup.
235
+ const SKIPPED_IN_CELLS = new Set([
236
+ "svg",
237
+ "math",
238
+ "style",
239
+ "script",
240
+ "textarea",
241
+ "title",
242
+ "xmp",
243
+ "iframe",
244
+ "noembed",
245
+ "noframes",
246
+ "noscript",
247
+ ]);
248
+
249
+ const breakLongCellRuns = (html: string): string => {
250
+ let skippedDepth = 0;
251
+ let buffer = "";
252
+ // Units of the run that the previous text node in the cell ended with.
253
+ let carried = 0;
254
+ const pieces = new RegExp(`(${CHARACTER_REFERENCE.source})|.`, "gsu");
255
+ const breakText = (text: string): string => {
256
+ let output = "";
257
+ let run: { raw: string; decoded: string }[] = [];
258
+ const flushRun = () => {
259
+ const units = runUnits(run);
260
+ run = [];
261
+ const start = carried;
262
+ carried += units.length;
263
+ // A run that continues in a later text node is counted when it gets
264
+ // there; earlier nodes stay as written, so at most 30 units stay whole.
265
+ if (carried <= MAX_WHOLE_CELL_RUN) {
266
+ output += units.join("");
267
+ return;
268
+ }
269
+ output += units
270
+ .map((unit, index) => ((start + index) % CELL_RUN_BREAK_STEP === 0 && start + index > 0 ? `<wbr>${unit}` : unit))
271
+ .join("");
272
+ };
273
+ for (const [raw, reference] of text.matchAll(pieces)) {
274
+ const decoded = reference === undefined ? raw : decodeHTML(raw);
275
+ // Literal and referenced whitespace end runs, except no-break characters.
276
+ if (/^[^\S\u00a0\u2007\u202f\ufeff]+$/u.test(decoded)) {
277
+ flushRun();
278
+ carried = 0;
279
+ output += raw;
280
+ } else {
281
+ run.push({ raw, decoded });
282
+ }
283
+ }
284
+ flushRun();
285
+ return output;
286
+ };
287
+
288
+ return new HTMLRewriter()
289
+ .on("td *, th *", {
290
+ element(element) {
291
+ if (INLINE_FORMATTING.has(element.tagName)) return;
292
+ carried = 0;
293
+ // Void elements and self-closing foreign elements have no end tag. HTML
294
+ // ignores the self-closing flag elsewhere: <textarea/> still opens raw text.
295
+ if (!element.canHaveContent) return;
296
+ const skipped = SKIPPED_IN_CELLS.has(element.tagName);
297
+ if (skipped) skippedDepth += 1;
298
+ // One end tag handler per element: a second registration would replace this one.
299
+ element.onEndTag(() => {
300
+ carried = 0;
301
+ if (skipped) skippedDepth -= 1;
302
+ });
303
+ },
304
+ })
305
+ .on("td, th", {
306
+ element() {
307
+ carried = 0;
308
+ },
309
+ text(chunk) {
310
+ if (skippedDepth) return;
311
+ // HTMLRewriter may split one text node (even an entity) into chunks.
312
+ buffer += chunk.text;
313
+ if (chunk.lastInTextNode) {
314
+ chunk.replace(breakText(buffer), { html: true });
315
+ buffer = "";
316
+ } else {
317
+ chunk.remove();
318
+ }
319
+ },
320
+ })
321
+ .transform(html);
322
+ };
323
+
168
324
  const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
169
325
 
170
326
  const escapeHtml = (value: string): string =>
@@ -258,7 +414,9 @@ export const buildPresetPdfHtml = (input: BuildPresetPdfHtmlInput): string => {
258
414
  const suppliedCustomCss = input.customCss?.trim() ?? "";
259
415
  const customCss = suppliedCustomCss ? validateCustomCss(input.customCss ?? "") : "";
260
416
  const presetCss = templateId ? TEMPLATE_CSS[templateId] : customCss ? "" : TEMPLATE_CSS.document;
261
- const baseCss = [presetCss, INFO_BLOCK_PRINT_CSS, LINK_PRINT_CSS, input.css ? styleText(input.css) : ""].filter(Boolean).join("\n");
417
+ const baseCss = [presetCss, INFO_BLOCK_PRINT_CSS, LINK_PRINT_CSS, TABLE_CELL_PRINT_CSS, input.css ? styleText(input.css) : ""]
418
+ .filter(Boolean)
419
+ .join("\n");
262
420
  const stylesheet = `${baseCss}${baseCss && customCss ? `\n/* Custom CSS overrides */\n` : ""}${customCss}`;
263
421
  return `<!doctype html>
264
422
  <html lang="en">
@@ -268,7 +426,7 @@ export const buildPresetPdfHtml = (input: BuildPresetPdfHtmlInput): string => {
268
426
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; img-src 'none'; media-src 'none'; object-src 'none'; script-src 'none'; style-src 'unsafe-inline'">
269
427
  <style>${stylesheet}</style>
270
428
  </head>
271
- <body><main class="markdown-document">${input.html}</main></body>
429
+ <body><main class="markdown-document">${breakLongCellRuns(input.html)}</main></body>
272
430
  </html>`;
273
431
  };
274
432
 
@@ -25,9 +25,12 @@ import {
25
25
  type SignInMethod,
26
26
  setBrowserCookie,
27
27
  } from "../known-browsers";
28
+ import { logger } from "../logging";
28
29
  import * as settings from "../settings";
29
30
  import { loadJwtSession, loadJwtSessionIdentity } from "./user";
30
31
 
32
+ const log = logger("auth:session");
33
+
31
34
  export type SessionKind = "web" | "app";
32
35
 
33
36
  export type SessionData = {
@@ -90,12 +93,13 @@ const verifyForRequest = (c: Context, token: string): Promise<CloudSessionClaims
90
93
  /** Moves the account's authentication epoch, which ends every session issued before it.
91
94
  * Request-time expiry uses only this: device operations check expiry themselves and an
92
95
  * administrator can lift it, so revoking devices would depend on which request came first. */
93
- const endSessions = async (db: SQL, userId: string): Promise<void> => {
94
- 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 }>>`
95
98
  UPDATE auth.users SET auth_epoch = auth_epoch + 1
96
- WHERE id = ${userId}::uuid RETURNING id
99
+ WHERE id = ${userId}::uuid RETURNING auth_epoch
97
100
  `;
98
101
  if (!updated) throw new Error("Cannot revoke sessions for an unknown user");
102
+ return Number(updated.auth_epoch);
99
103
  };
100
104
 
101
105
  const authenticateVerified = async (credential: CloudSessionClaims): Promise<AuthenticatedSession | null> => {
@@ -291,7 +295,7 @@ const issueJwtSession = async (
291
295
  options: SessionIssueOptions,
292
296
  attempt = 0,
293
297
  committedNotice: NewBrowserSignInNotice | null = null,
294
- ): Promise<{ token: string; notice: NewBrowserSignInNotice | null }> => {
298
+ ): Promise<{ token: string; expiresAt: Date; notice: NewBrowserSignInNotice | null }> => {
295
299
  const signer = await prepareIdentitySigner("session");
296
300
  let family: PreparedFamily;
297
301
  try {
@@ -305,7 +309,7 @@ const issueJwtSession = async (
305
309
  }
306
310
  const token = await signFamily(family);
307
311
  const notice = committedNotice ?? family.notice;
308
- if (token) return { token, notice };
312
+ if (token) return { token, expiresAt: family.expiresAt, notice };
309
313
  if (attempt === 0) return issueJwtSession(c, userId, { ...options, sid: undefined }, 1, notice);
310
314
  throw new Error("Cloud session signer changed repeatedly during issuance");
311
315
  };
@@ -346,6 +350,176 @@ const issueInTransaction = async <T>(
346
350
  }
347
351
  };
348
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
+ }
520
+ }
521
+ };
522
+
349
523
  /** Today's web credential: an explicit non-API bearer, otherwise the `session_token` cookie. */
350
524
  const getWebOrBearerToken = (c: Context): string | null => {
351
525
  const authorization = c.req.header("Authorization");
@@ -416,7 +590,11 @@ export const session = {
416
590
  const ttlSeconds = Math.floor(expiryHours * 60 * 60);
417
591
  const cookie = readBrowserCookie(c) ?? newBrowserCookie();
418
592
  const { browser, os, country, city } = describeRequest(c);
419
- const { token: clientToken, notice } = await issueJwtSession(c, userId, {
593
+ const {
594
+ token: clientToken,
595
+ expiresAt,
596
+ notice,
597
+ } = await issueJwtSession(c, userId, {
420
598
  ttlSeconds,
421
599
  authEpoch: signIn.authEpoch,
422
600
  appDeviceId: signIn.appDeviceId,
@@ -435,17 +613,25 @@ export const session = {
435
613
  // Handoff precedes the response so the person signing in cannot turn the notice off first.
436
614
  if (signIn.notify !== null && notice) await announceNewBrowser(signIn.notify, notice);
437
615
 
438
- 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, {
439
625
  httpOnly: true,
440
626
  secure: !env.IS_DEVELOPMENT,
441
627
  sameSite: "Lax",
442
- maxAge: ttlSeconds,
628
+ maxAge: Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1_000)),
443
629
  path: "/",
444
630
  });
445
- setBrowserCookie(c, cookie);
446
- return clientToken;
447
631
  },
448
632
 
633
+ endOtherSignIns,
634
+
449
635
  revoke: revokeToken,
450
636
 
451
637
  /** Signs out the web session (or an explicit bearer session). The mobile app's session is unaffected. */
@@ -457,25 +643,12 @@ export const session = {
457
643
 
458
644
  issueInTransaction,
459
645
 
460
- /** Signs the account out everywhere: every session, every paired sign-in device and every paired phone, audited per device. */
461
- revokeAllForUser: async (userId: string): Promise<void> => {
462
- await sql.begin(async (tx) => {
463
- await endSessions(tx, userId);
464
- // The epoch change already ends the phones; revoking them keeps lists and the audit log truthful.
465
- const phones = await tx<Array<{ id: string }>>`
466
- UPDATE auth.pwa_devices SET revoked_at = now(), revocation_reason = 'sign_out_everywhere'
467
- WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
468
- `;
469
- for (const phone of phones)
470
- await audit.record(
471
- {
472
- action: "auth.pwa.device.revoke",
473
- outcome: "allowed",
474
- target: { type: "pwa_device", id: phone.id },
475
- metadata: { targetUserId: userId, provenance: "system", reason: "sign_out_everywhere" },
476
- },
477
- tx,
478
- );
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");
479
652
  // A paired device approves new sign-ins on its own, so a lost one would outlive the epoch change.
480
653
  const devices = await tx<Array<{ id: string }>>`
481
654
  UPDATE auth.app_devices SET revoked_at = now() WHERE user_id = ${userId}::uuid AND revoked_at IS NULL RETURNING id
@@ -490,6 +663,7 @@ export const session = {
490
663
  },
491
664
  tx,
492
665
  );
666
+ return authEpoch;
493
667
  });
494
668
  },
495
669