@k2b/cloud 0.37.0 → 0.39.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 (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
@@ -3,6 +3,7 @@ import { lazySync } from "../_internal/process-sync";
3
3
  import { env } from "../config/env";
4
4
  import { type AccountCategory, accountCategory } from "../contracts/account-categories";
5
5
  import {
6
+ APP_ACTION_LIMITS,
6
7
  APP_APPROVAL_FEATURES,
7
8
  APP_APPROVAL_PATH,
8
9
  APP_APPROVAL_PROTOCOL,
@@ -19,9 +20,11 @@ import {
19
20
  } from "../contracts/app-approval";
20
21
  import { publicCloudOrigin } from "../shared/app-url";
21
22
  import { isAccountCategoryAllowed } from "./account-category-policy";
23
+ import { appApprovalText } from "./approvals/device";
22
24
  import { audit } from "./audit";
23
25
  import { logger } from "./logging";
24
26
  import { pairingSecret } from "./pairing-secret";
27
+ import { toPgTextArray } from "./postgres";
25
28
  import { requireEligibleAccount, requireRecentWebSession } from "./session/recent";
26
29
  import { CORE_SETTINGS } from "./settings/core-settings";
27
30
  import { decryptValue } from "./settings/crypto";
@@ -109,6 +112,7 @@ type DeviceRow = {
109
112
  created_at: Date;
110
113
  last_used_at: Date | null;
111
114
  assisted: boolean;
115
+ accepts: string[] | null;
112
116
  };
113
117
  type PairingRow = {
114
118
  id: string;
@@ -127,6 +131,11 @@ type PairingRow = {
127
131
  comparison: string | null;
128
132
  };
129
133
  type LoginRow = {
134
+ kind: "browser" | "terminal";
135
+ device_authorization_id: string | null;
136
+ client_id: string | null;
137
+ scopes: string[] | null;
138
+ lifetime_seconds: number | null;
130
139
  id: string;
131
140
  user_id: string | null;
132
141
  auth_epoch: string | null;
@@ -164,9 +173,30 @@ export type AppDeviceActions = {
164
173
  approved: boolean;
165
174
  }): Promise<boolean>;
166
175
  };
167
- // Bundle Action wake-ups for 30 seconds while the previously woken request is still open.
176
+ export type AppTerminalLoginInput = {
177
+ deviceAuthorizationId: string;
178
+ identifier: string;
179
+ clientId: string;
180
+ scopes: string[];
181
+ lifetimeSeconds: number;
182
+ };
183
+ export type AppTerminalLogins = {
184
+ /** Live pending authorizations whose registry client still allows the device grant. */
185
+ describe(tx: SQL, ids: string[]): Promise<Map<string, { id: string; name: string }>>;
186
+ /** In the sign-in decision transaction; false aborts both decisions and their audits. */
187
+ decide(
188
+ tx: SQL,
189
+ input: {
190
+ deviceAuthorizationId: string;
191
+ clientId: string;
192
+ user: Pick<AccountRow, "id" | "uid" | "provider" | "profile">;
193
+ decision: "approve" | "deny";
194
+ },
195
+ ): Promise<boolean>;
196
+ };
197
+ // Bundle sign-in and Action wake-ups for 30 seconds while the previously woken request is still open.
168
198
  // Cloud Login gives Actions a separate 30-per-token budget in five minutes, preserving the full sign-in budget.
169
- const ACTION_WAKE_SECONDS = 30;
199
+ const WAKE_SECONDS = 30;
170
200
  /** Best-effort sign-in wake-ups through the trusted authenticator; returns the HTTP status. */
171
201
  export type AppLoginPushSender = (
172
202
  appOrigin: string,
@@ -247,7 +277,7 @@ export const createAppApprovalService = (
247
277
  // Bounded opportunistic maintenance: each accepted write retires up to 100
248
278
  // expired rows per table, so bursts cannot trigger an unbounded sweep.
249
279
  await db`DELETE FROM auth.app_pairings WHERE id IN (SELECT id FROM auth.app_pairings WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
250
- await db`DELETE FROM auth.app_logins WHERE id IN (SELECT id FROM auth.app_logins WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
280
+ await db`DELETE FROM auth.app_logins WHERE id IN (SELECT id FROM auth.app_logins WHERE expires_at < now() AND NOT (kind='terminal' AND state='approved') ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
251
281
  await db`DELETE FROM auth.app_device_proofs WHERE (device_id,jti) IN (SELECT device_id,jti FROM auth.app_device_proofs WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
252
282
  };
253
283
  const authorizePairing = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
@@ -269,12 +299,18 @@ export const createAppApprovalService = (
269
299
 
270
300
  /** Runs after the login commit and never delays or fails it. The body names only
271
301
  * this Cloud and the request id; a 410 means the phone's subscription is gone. */
272
- const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string) => {
302
+ const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string, options?: { terminal: boolean }) => {
273
303
  try {
274
- const rows = await db<{ push_token: string }[]>`SELECT DISTINCT push_token FROM auth.app_devices
275
- WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL LIMIT ${limits.devicesPerAccount}`;
304
+ const rows = await db<{ push_token: string }[]>`
305
+ UPDATE auth.app_devices d SET login_wake_at=now(), login_wake_ref=${requestId}::uuid
306
+ WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL
307
+ AND (${options?.terminal ?? false}=false OR 'terminal-logins'=ANY(d.accepts))
308
+ AND (d.login_wake_at IS NULL OR d.login_wake_at <= now() - ${WAKE_SECONDS} * interval '1 second'
309
+ OR NOT EXISTS (SELECT 1 FROM auth.app_logins w
310
+ WHERE w.id=d.login_wake_ref AND w.state='pending' AND w.expires_at > now()))
311
+ RETURNING push_token`;
276
312
  await Promise.all(
277
- rows.map(async ({ push_token: token }) => {
313
+ Array.from(new Set(rows.map((row) => row.push_token)), async (token) => {
278
314
  const status = await push(cfg.appOrigin, { token, cloudOrigin: cfg.issuer, requestRef: requestId });
279
315
  if (status === 410)
280
316
  await db`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
@@ -285,6 +321,31 @@ export const createAppApprovalService = (
285
321
  }
286
322
  };
287
323
 
324
+ const loginCandidate = async (tx: SQL, identifier: string, category?: AccountCategory) => {
325
+ const candidates = await tx<AccountRow[]>`
326
+ SELECT id,uid,provider,profile,auth_epoch,account_expires FROM auth.users
327
+ WHERE lower(uid)=lower(${identifier}) OR lower(btrim(mail))=lower(${identifier}) LIMIT 2`;
328
+ const candidate = candidates.length === 1 ? candidates[0] : undefined;
329
+ if (
330
+ candidate &&
331
+ ((category !== undefined && accountCategory(candidate) !== category) ||
332
+ (candidate.account_expires && new Date(candidate.account_expires).getTime() <= Date.now()) ||
333
+ !(await isAccountCategoryAllowed(candidate, tx)))
334
+ )
335
+ return undefined;
336
+ if (!candidate) return undefined;
337
+ // Re-read under the account lock: starts share one budget, and epoch changes cannot race the snapshot.
338
+ const [locked] = await tx<AccountRow[]>`SELECT id,uid,provider,profile,auth_epoch,account_expires
339
+ FROM auth.users WHERE id=${candidate.id}::uuid FOR UPDATE`;
340
+ if (locked?.account_expires && new Date(locked.account_expires).getTime() <= Date.now()) return undefined;
341
+ return locked;
342
+ };
343
+ const loginBudget = async (tx: SQL, issuer: string, userId: string) => {
344
+ const [count] = await tx<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.app_logins
345
+ WHERE issuer=${issuer} AND user_id=${userId}::uuid AND expires_at > now() AND state IN ('pending','approved')`;
346
+ return (count?.count ?? 0) < limits.pendingPerAccount;
347
+ };
348
+
288
349
  return {
289
350
  config,
290
351
  cleanup,
@@ -303,7 +364,7 @@ export const createAppApprovalService = (
303
364
  AND device.issuer=${cfg.issuer} AND device.revoked_at IS NULL AND device.push_token IS NOT NULL
304
365
  AND device.actions_at IS NOT NULL
305
366
  LIMIT ${limits.devicesPerAccount}
306
- ) AND (d.action_wake_at IS NULL OR d.action_wake_at <= now() - ${ACTION_WAKE_SECONDS} * interval '1 second'
367
+ ) AND (d.action_wake_at IS NULL OR d.action_wake_at <= now() - ${WAKE_SECONDS} * interval '1 second'
307
368
  OR NOT EXISTS (SELECT 1 FROM approvals.requests w
308
369
  WHERE w.id=d.action_wake_ref AND w.status='pending' AND w.expires_at > now()))
309
370
  RETURNING d.push_token,d.user_id`;
@@ -505,24 +566,8 @@ export const createAppApprovalService = (
505
566
  expiresAt = future(limits.loginSeconds);
506
567
  let owner: string | undefined;
507
568
  await db.begin(async (tx) => {
508
- const candidates = await tx<
509
- AccountRow[]
510
- >`SELECT id,uid,provider,profile,auth_epoch,account_expires FROM auth.users WHERE lower(uid)=lower(${identifier}) OR lower(btrim(mail))=lower(${identifier}) LIMIT 2`;
511
- let candidate = candidates.length === 1 ? candidates[0] : undefined;
512
- if (
513
- candidate &&
514
- (accountCategory(candidate) !== category ||
515
- (candidate.account_expires && new Date(candidate.account_expires).getTime() <= Date.now()) ||
516
- !(await isAccountCategoryAllowed(candidate, tx)))
517
- )
518
- candidate = undefined;
519
- if (candidate) {
520
- await tx`SELECT id FROM auth.users WHERE id=${candidate.id}::uuid FOR UPDATE`;
521
- const [count] = await tx<
522
- { count: number }[]
523
- >`SELECT count(*)::int AS count FROM auth.app_logins WHERE issuer=${cfg.issuer} AND user_id=${candidate.id}::uuid AND expires_at > now() AND state IN ('pending','approved')`;
524
- if ((count?.count ?? 0) >= limits.pendingPerAccount) candidate = undefined;
525
- }
569
+ let candidate = await loginCandidate(tx, identifier, category);
570
+ if (candidate && !(await loginBudget(tx, cfg.issuer, candidate.id))) candidate = undefined;
526
571
  // Unknown, ambiguous, disabled and throttled accounts have the identical
527
572
  // public shape and a decoy pending transaction; no account enumeration.
528
573
  await tx`INSERT INTO auth.app_logins(id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at)
@@ -533,7 +578,38 @@ export const createAppApprovalService = (
533
578
  if (owner) void wake(cfg, owner, id);
534
579
  return { requestId: id, browserSecret, comparison: code, expiresAt: iso(expiresAt), pollAfterSeconds: limits.pollSeconds };
535
580
  },
536
- deviceCommand: async (request: AppDeviceRequest, actions: AppDeviceActions) => {
581
+ startTerminalLogin: async (
582
+ input: AppTerminalLoginInput,
583
+ access: (tx: SQL, account: Pick<AccountRow, "id" | "profile">) => Promise<boolean>,
584
+ ) => {
585
+ const cfg = await config();
586
+ await cleanup();
587
+ const id = crypto.randomUUID(),
588
+ code = comparison(),
589
+ expiresAt = future(limits.loginSeconds);
590
+ let owner: string | undefined;
591
+ await db.begin(async (tx) => {
592
+ let candidate = await loginCandidate(tx, input.identifier);
593
+ const subject = candidate ?? { id: crypto.randomUUID(), profile: "user" as const };
594
+ // Even a decoy performs the client-access, device and shared-budget queries.
595
+ const allowed = await access(tx, subject);
596
+ const devices = await tx`SELECT id FROM auth.app_devices WHERE issuer=${cfg.issuer}
597
+ AND user_id=${subject.id}::uuid AND revoked_at IS NULL LIMIT 1`;
598
+ const budget = await loginBudget(tx, cfg.issuer, subject.id);
599
+ if (!allowed || !devices.length || !budget) candidate = undefined;
600
+ const inserted = await tx`INSERT INTO auth.app_logins
601
+ (id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at,kind,device_authorization_id,client_id,scopes,lifetime_seconds)
602
+ VALUES (${id}::uuid,${cfg.issuer},${candidate?.id ?? null}::uuid,${candidate?.auth_epoch ?? null},
603
+ ${candidate ? accountCategory(candidate) : "login"},${hash(secret())},${secret()},${code},${expiresAt},'terminal',
604
+ ${input.deviceAuthorizationId}::uuid,${input.clientId},${toPgTextArray(input.scopes)}::text[],${input.lifetimeSeconds})
605
+ ON CONFLICT (device_authorization_id) WHERE device_authorization_id IS NOT NULL DO NOTHING RETURNING id`;
606
+ if (!inserted.length) return reject("CONFLICT", 409);
607
+ owner = candidate?.id;
608
+ });
609
+ if (owner) void wake(cfg, owner, id, { terminal: true });
610
+ return { comparison: code, expiresAt: iso(expiresAt) };
611
+ },
612
+ deviceCommand: async (request: AppDeviceRequest, actions: AppDeviceActions, terminal?: AppTerminalLogins) => {
537
613
  const cfg = await config();
538
614
  const { proof, signature } = request;
539
615
  const now = Math.floor(Date.now() / 1000);
@@ -580,20 +656,40 @@ export const createAppApprovalService = (
580
656
  return { operation: command.operation, userId: device.user_id, deviceId: device.id };
581
657
  if (command.operation === "pending" || command.operation === "inbox") {
582
658
  const user = await eligible(tx, device.user_id);
659
+ const acceptsTerminal =
660
+ command.operation === "inbox" && command.accept?.includes("terminal-logins") === true && terminal !== undefined;
583
661
  const rows = await tx<
584
662
  LoginRow[]
585
- >`SELECT * FROM auth.app_logins WHERE issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid AND state='pending' AND expires_at>now() AND auth_epoch=${user.auth_epoch} AND category=${accountCategory(user)} ORDER BY created_at LIMIT ${limits.pendingPerAccount}`;
586
- const requests = rows.map(
587
- (row): AppPendingLogin => ({
663
+ >`SELECT * FROM auth.app_logins WHERE (kind='browser' OR ${acceptsTerminal}) AND issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid AND state='pending' AND expires_at>now() AND auth_epoch=${user.auth_epoch} AND category=${accountCategory(user)} ORDER BY created_at LIMIT ${limits.pendingPerAccount}`;
664
+ const clients =
665
+ acceptsTerminal && terminal
666
+ ? await terminal.describe(
667
+ tx,
668
+ rows.flatMap((row) => (row.device_authorization_id ? [row.device_authorization_id] : [])),
669
+ )
670
+ : new Map<string, { id: string; name: string }>();
671
+ const requests: AppPendingLogin[] = [];
672
+ for (const row of rows) {
673
+ const login: AppPendingLogin = {
588
674
  requestId: row.id,
589
675
  challenge: row.challenge,
590
676
  comparison: row.comparison,
591
677
  createdAt: iso(row.created_at),
592
678
  expiresAt: iso(row.expires_at),
593
- }),
594
- );
679
+ };
680
+ if (row.kind === "terminal") {
681
+ const client = row.device_authorization_id ? clients.get(row.device_authorization_id) : undefined;
682
+ if (!client || client.id !== row.client_id || !row.scopes || !row.lifetime_seconds) continue;
683
+ login.terminal = {
684
+ client: { id: client.id, name: appApprovalText(client.name || client.id, APP_ACTION_LIMITS.nameChars) || "OAuth" },
685
+ scopes: row.scopes,
686
+ lifetimeSeconds: row.lifetime_seconds,
687
+ };
688
+ }
689
+ requests.push(login);
690
+ }
595
691
  if (command.operation === "inbox") {
596
- await tx`UPDATE auth.app_devices SET actions_at=now() WHERE id=${device.id}::uuid`;
692
+ await tx`UPDATE auth.app_devices SET actions_at=now(), accepts=${toPgTextArray(command.accept ?? [])}::text[] WHERE id=${device.id}::uuid`;
597
693
  return { operation: "inbox" as const, userId: device.user_id, logins: requests };
598
694
  }
599
695
  return { requests, pollAfterSeconds: limits.pollSeconds };
@@ -612,9 +708,29 @@ export const createAppApprovalService = (
612
708
  row.category !== accountCategory(user)
613
709
  )
614
710
  return reject("CONFLICT", 409);
711
+ if (
712
+ row.kind === "terminal" &&
713
+ (!device.accepts?.includes("terminal-logins") ||
714
+ !terminal ||
715
+ !row.device_authorization_id ||
716
+ !row.client_id ||
717
+ !(await terminal.decide(tx, {
718
+ deviceAuthorizationId: row.device_authorization_id,
719
+ clientId: row.client_id,
720
+ user,
721
+ decision: command.decision,
722
+ })))
723
+ )
724
+ return reject("CONFLICT", 409);
615
725
  const state = command.decision === "approve" ? "approved" : "denied";
616
- await tx`UPDATE auth.app_logins SET state=${state},approved_by=${device.id}::uuid WHERE id=${row.id}::uuid`;
617
- await record(tx, `login.${command.decision}`, device.user_id, device.id, { requestId: row.id });
726
+ // A hook may wait on another transaction. Expiry still wins before either decision commits.
727
+ const decided = await tx`UPDATE auth.app_logins SET state=${state},approved_by=${device.id}::uuid
728
+ WHERE id=${row.id}::uuid AND expires_at > clock_timestamp() RETURNING id`;
729
+ if (!decided.length) return reject("CONFLICT", 409);
730
+ await record(tx, `login.${command.decision}`, device.user_id, device.id, {
731
+ requestId: row.id,
732
+ ...(row.kind === "terminal" ? { kind: "terminal", clientId: row.client_id, deviceGrantId: row.device_authorization_id } : {}),
733
+ });
618
734
  return { state };
619
735
  });
620
736
  if (outcome.operation !== undefined) {
@@ -655,7 +771,7 @@ export const createAppApprovalService = (
655
771
  browserStatus: async (id: string, token: string, hold?: { ms: number; signal?: AbortSignal }) => {
656
772
  const cfg = await config();
657
773
  const read = async () => {
658
- const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
774
+ const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer}`;
659
775
  if (!row || !matches(token, row.browser_hash)) return reject("UNAVAILABLE", 404);
660
776
  const expiresIn = new Date(row.expires_at).getTime() - Date.now();
661
777
  return { state: expiresIn <= 0 ? "expired" : row.state, expiresIn };
@@ -686,10 +802,14 @@ export const createAppApprovalService = (
686
802
  const cfg = await config();
687
803
  return db.begin(async (tx) => {
688
804
  // Lock order is device -> request for both approval and completion.
689
- const [snapshot] = await tx<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
805
+ const [snapshot] = await tx<
806
+ LoginRow[]
807
+ >`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer}`;
690
808
  if (!snapshot || !snapshot.approved_by || !matches(token, snapshot.browser_hash)) return reject("CONFLICT", 409);
691
809
  const device = await activeDevice(tx, snapshot.approved_by, cfg.issuer);
692
- const [row] = await tx<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
810
+ const [row] = await tx<
811
+ LoginRow[]
812
+ >`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
693
813
  const user = await eligible(tx, device.user_id);
694
814
  if (
695
815
  !row ||
@@ -21,6 +21,8 @@ const cut = (value: string, limit: number): string => {
21
21
  return points.length > limit ? `${points.slice(0, limit - 1).join("")}…` : value;
22
22
  };
23
23
 
24
+ export const appApprovalText = (value: string, limit: number): string => cut(plain(value), limit);
25
+
24
26
  /** A deterministic, plain-text snapshot; the digest binds the device's choice to everything it shows. */
25
27
  export const appActionView = async (
26
28
  row: PendingApprovalRow,
@@ -4,7 +4,8 @@ import { getApp, listApps } from "../../_internal/registry";
4
4
  import type { AppRegistryEntry } from "../../contracts/registry";
5
5
  import type { RequestActor } from "../../contracts/shared";
6
6
  import { resolveAppPresentations } from "../../shared/app-presentation";
7
- import { helpLocaleChain } from "../../shared/help";
7
+ import { type HelpArticleLink, helpLocaleChain, parseHelpReference } from "../../shared/help";
8
+ import { lexHelpMarkdown, walkHelpTokens } from "../../shared/markdown";
8
9
  import { logger } from "../logging";
9
10
  import { HELP_RESOURCE_PAGE_SIZE, type HelpSelection, hasHelpBm25, metadata, queryHelp } from "./store";
10
11
  import type { HelpReader } from "./types";
@@ -36,9 +37,8 @@ export const createHelpReader = (
36
37
  ): HelpReader => {
37
38
  const db = dependencies.db ?? sql;
38
39
  const visible = (app: AppRegistryEntry | null): app is AppRegistryEntry => app !== null && canReadAppHelp(app, viewer);
39
- const select = async (appId?: string): Promise<HelpSelection[]> => {
40
- const apps = appId ? [await (dependencies.getApp ?? getApp)(appId)] : await (dependencies.listApps ?? listApps)();
41
- return resolveAppPresentations(apps.filter(visible), locale).flatMap((app) =>
40
+ const selectionFor = (apps: AppRegistryEntry[]): HelpSelection[] =>
41
+ apps.flatMap((app) =>
42
42
  app.help
43
43
  ? [
44
44
  {
@@ -50,6 +50,9 @@ export const createHelpReader = (
50
50
  ]
51
51
  : [],
52
52
  );
53
+ const select = async (appId?: string): Promise<HelpSelection[]> => {
54
+ const apps = appId ? [await (dependencies.getApp ?? getApp)(appId)] : await (dependencies.listApps ?? listApps)();
55
+ return selectionFor(resolveAppPresentations(apps.filter(visible), locale));
53
56
  };
54
57
  return {
55
58
  async manifest(appId) {
@@ -80,6 +83,7 @@ export const createHelpReader = (
80
83
  icon: row.icon ?? undefined,
81
84
  description: row.description ?? undefined,
82
85
  order: row.sort_order,
86
+ kind: row.kind,
83
87
  locale: row.locale,
84
88
  searchUrl: `/api/help/v1/${encodeURIComponent(appId)}/search`,
85
89
  url: `/api/help/v1/${encodeURIComponent(appId)}/documents/${encodeURIComponent(row.document_id)}`,
@@ -91,7 +95,44 @@ export const createHelpReader = (
91
95
  },
92
96
  async read(input) {
93
97
  const [row] = await queryHelp(await select(input.appId), { documentId: input.documentId, body: true, limit: 1 }, db);
94
- return row?.markdown === undefined ? null : { ...metadata(row), markdown: row.markdown };
98
+ if (row?.markdown === undefined) return null;
99
+ const references = new Set<string>();
100
+ const add = (reference: string) => {
101
+ const ref = parseHelpReference(reference);
102
+ if (ref) references.add(`${ref.appId ?? input.appId}/${ref.documentId}`);
103
+ };
104
+ if (row.next_id) add(row.next_id);
105
+ for (const reference of row.related) add(reference);
106
+ walkHelpTokens(lexHelpMarkdown(row.markdown, row.locale), (token) => {
107
+ if (token.type === "link" && token.href.startsWith("help:")) add(token.href.slice(5));
108
+ });
109
+ // Only targets in the current Help of apps this viewer may see become links; the rest stay text.
110
+ const referencedApps = new Set([...references].map((reference) => reference.split("/")[0]));
111
+ const apps = references.size
112
+ ? resolveAppPresentations(
113
+ (referencedApps.size === 1 && referencedApps.has(input.appId)
114
+ ? [await (dependencies.getApp ?? getApp)(input.appId)]
115
+ : await (dependencies.listApps ?? listApps)()
116
+ ).filter((app): app is AppRegistryEntry => visible(app) && referencedApps.has(app.id)),
117
+ locale,
118
+ )
119
+ : [];
120
+ const targets = apps.length ? await queryHelp(selectionFor(apps), { references: [...references] }, db) : [];
121
+ const accents = new Map(apps.map((app) => [app.id, app.appearance?.accent]));
122
+ const links: Record<string, HelpArticleLink> = {};
123
+ for (const target of targets) {
124
+ links[`${target.app_id}/${target.document_id}`] = {
125
+ appId: target.app_id,
126
+ appName: target.app_name,
127
+ accent: accents.get(target.app_id),
128
+ documentId: target.document_id,
129
+ title: target.title,
130
+ description: target.description ?? undefined,
131
+ icon: target.icon ?? undefined,
132
+ url: `/help/apps/${encodeURIComponent(target.app_id)}/${encodeURIComponent(target.document_id)}`,
133
+ };
134
+ }
135
+ return { ...metadata(row), markdown: row.markdown, links };
95
136
  },
96
137
  async search(input) {
97
138
  const query = input.query.trim().slice(0, 200);
@@ -1,6 +1,7 @@
1
1
  import { type SQL, sql } from "bun";
2
2
  import { APP_REGISTRY_TTL_MS } from "../../_internal/registry";
3
- import { ensureSchema } from "../postgres";
3
+ import type { HelpAccessLevel, HelpKind } from "../../shared/help";
4
+ import { ensureSchema, toPgTextArray } from "../postgres";
4
5
  import type { HelpCorpus, HelpMetadata } from "./types";
5
6
 
6
7
  // One expired app lease of grace after an app can no longer be discovered.
@@ -26,6 +27,15 @@ export const migrateHelp = async (db: SQL = sql): Promise<void> => {
26
27
  PRIMARY KEY (app_id, manifest_hash, locale, document_id),
27
28
  FOREIGN KEY (app_id, manifest_hash) REFERENCES help.corpora ON DELETE CASCADE
28
29
  )`.simple();
30
+ const columns = await tx`SELECT column_name FROM information_schema.columns
31
+ WHERE table_schema = 'help' AND table_name = 'documents'
32
+ AND column_name IN ('kind', 'needs', 'next_id', 'related')`;
33
+ if (columns.length < 4)
34
+ await tx`ALTER TABLE help.documents
35
+ ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'task',
36
+ ADD COLUMN IF NOT EXISTS needs text,
37
+ ADD COLUMN IF NOT EXISTS next_id text,
38
+ ADD COLUMN IF NOT EXISTS related text[] NOT NULL DEFAULT '{}'`.simple();
29
39
  await tx`CREATE INDEX IF NOT EXISTS help_search_idx ON help.documents USING gin(search_document)`.simple();
30
40
  });
31
41
  // The operator enables the extension; Help never installs server extensions.
@@ -63,9 +73,9 @@ export const registerHelp = async (corpus: HelpCorpus, db: SQL = sql): Promise<v
63
73
  for (const document of documents) {
64
74
  const text = `${document.title}\n${document.description ?? ""}\n${document.searchText}`;
65
75
  await tx`INSERT INTO help.documents(app_id, manifest_hash, locale, document_id, title, description, icon,
66
- sort_order, markdown, search_text, search_config, search_document)
76
+ sort_order, kind, needs, next_id, related, markdown, search_text, search_config, search_document)
67
77
  VALUES (${corpus.appId}, ${corpus.manifestHash}, ${locale}, ${document.id}, ${document.title},
68
- ${document.description ?? null}, ${document.icon ?? null}, ${document.order}, ${document.markdown}, ${text}, ${config},
78
+ ${document.description ?? null}, ${document.icon ?? null}, ${document.order}, ${document.kind}, ${document.needs ?? null}, ${document.next ?? null}, ${toPgTextArray([...document.related])}::text[], ${document.markdown}, ${text}, ${config},
69
79
  setweight(to_tsvector(${config}::regconfig, ${document.id + " " + document.title}), 'A') ||
70
80
  setweight(to_tsvector(${config}::regconfig, ${document.description ?? ""}), 'B') ||
71
81
  setweight(to_tsvector(${config}::regconfig, ${document.searchText}), 'D'))`;
@@ -97,6 +107,10 @@ type Row = {
97
107
  description: string | null;
98
108
  icon: string | null;
99
109
  sort_order: number;
110
+ kind: HelpKind;
111
+ needs: HelpAccessLevel | null;
112
+ next_id: string | null;
113
+ related: string[];
100
114
  markdown?: string;
101
115
  };
102
116
  export const metadata = (row: Row): HelpMetadata => ({
@@ -109,6 +123,10 @@ export const metadata = (row: Row): HelpMetadata => ({
109
123
  description: row.description ?? undefined,
110
124
  icon: row.icon ?? undefined,
111
125
  order: row.sort_order,
126
+ kind: row.kind,
127
+ needs: row.needs ?? undefined,
128
+ next: row.next_id ?? undefined,
129
+ related: row.related,
112
130
  });
113
131
 
114
132
  export const hasHelpBm25 = async (db: SQL = sql): Promise<boolean> => {
@@ -130,6 +148,7 @@ export const queryHelp = async (
130
148
  limit?: number;
131
149
  cursor?: string;
132
150
  bm25?: boolean;
151
+ references?: readonly string[];
133
152
  },
134
153
  db: SQL = sql,
135
154
  ): Promise<Row[]> => {
@@ -145,10 +164,11 @@ export const queryHelp = async (
145
164
  const pending = db.unsafe<Row[]>(
146
165
  `WITH active AS (
147
166
  SELECT * FROM jsonb_to_recordset(($1::text)::jsonb) AS a(app_id text, app_name text, manifest_hash text, locales text[])
148
- ) SELECT d.app_id, a.app_name, d.manifest_hash, d.locale, d.document_id, d.title, d.description, d.icon, d.sort_order
167
+ ) SELECT d.app_id, a.app_name, d.manifest_hash, d.locale, d.document_id, d.title, d.description, d.icon, d.sort_order, d.kind, d.needs, d.next_id, d.related
149
168
  ${input.body ? ", d.markdown" : ""}
150
169
  FROM help.documents d JOIN active a ON a.app_id=d.app_id AND a.manifest_hash=d.manifest_hash
151
170
  WHERE length($2::text) >= 0 AND d.locale=ANY(a.locales)
171
+ AND ($5::text[] IS NULL OR (d.app_id || '/' || d.document_id)=ANY($5::text[]))
152
172
  AND NOT EXISTS (SELECT 1 FROM help.documents preferred
153
173
  WHERE preferred.app_id=d.app_id AND preferred.manifest_hash=d.manifest_hash AND preferred.document_id=d.document_id
154
174
  AND preferred.locale=ANY(a.locales) AND array_position(a.locales, preferred.locale)<array_position(a.locales, d.locale))
@@ -162,12 +182,13 @@ export const queryHelp = async (
162
182
  : ""
163
183
  }
164
184
  d.app_id COLLATE "C", ${input.cursor !== undefined ? "" : "d.sort_order,"} d.document_id COLLATE "C"
165
- ${input.limit !== undefined ? "LIMIT $5" : ""}`,
185
+ ${input.limit !== undefined ? "LIMIT $6" : ""}`,
166
186
  [
167
187
  JSON.stringify(selection),
168
188
  input.query ?? "",
169
189
  input.documentId ?? null,
170
190
  input.cursor ?? null,
191
+ input.references ? toPgTextArray([...input.references]) : null,
171
192
  ...(input.limit !== undefined ? [input.limit] : []),
172
193
  ],
173
194
  );
@@ -1,6 +1,6 @@
1
1
  import type { AppRegistryHelpSummary } from "../../contracts/registry";
2
2
  import type { RequestActor } from "../../contracts/shared";
3
- import type { HelpDocumentManifest } from "../../shared/help";
3
+ import type { HelpAccessLevel, HelpArticleLink, HelpDocumentManifest, HelpKind } from "../../shared/help";
4
4
 
5
5
  export type HelpDocument = {
6
6
  id: string;
@@ -8,6 +8,10 @@ export type HelpDocument = {
8
8
  icon?: string;
9
9
  description?: string;
10
10
  order: number;
11
+ kind: HelpKind;
12
+ needs?: HelpAccessLevel;
13
+ next?: string;
14
+ related: readonly string[];
11
15
  markdown: string;
12
16
  searchText: string;
13
17
  };
@@ -30,8 +34,12 @@ export type HelpMetadata = {
30
34
  description?: string;
31
35
  icon?: string;
32
36
  order: number;
37
+ kind: HelpKind;
38
+ needs?: HelpAccessLevel;
39
+ next?: string;
40
+ related: readonly string[];
33
41
  };
34
- export type HelpArticle = HelpMetadata & { markdown: string };
42
+ export type HelpArticle = HelpMetadata & { markdown: string; links: Readonly<Record<string, HelpArticleLink>> };
35
43
  export type HelpMatch = Pick<HelpMetadata, "appId" | "appName" | "locale" | "documentId" | "title" | "description"> & { kind: "help" };
36
44
  export type HelpSearchInput = { query: string; appId?: string; limit?: number };
37
45
  export type HelpReadInput = { appId: string; documentId: string };
@@ -8,6 +8,7 @@ export { accountIdentities, AccountIdentityError } from "./accounts/identities";
8
8
  export type { AccountIdentityUser, AccountIdentityGroup, AccountIdentityPage, AccountIdentityAvailability } from "./accounts/identities";
9
9
  export type { AccountIdentityReconciliation } from "./accounts/identity-reconciliation";
10
10
  export { providers } from "./providers";
11
+ export { createAllowedNetworkFetch } from "./allowed-network-fetch";
11
12
  export { requestPublicHttps, type PublicHttpInput } from "./public-http";
12
13
  export { authFlows } from "./auth-flows";
13
14
  export { toPgIntArray, toPgTextArray, toPgUuidArray, escapeLikePattern, isUniqueViolation } from "./postgres";
@@ -113,8 +114,8 @@ export type { AccountLifecycleNotificationSender } from "./account-lifecycle/not
113
114
  export { lifecycleJobs } from "./account-lifecycle/scheduler";
114
115
 
115
116
  export { settings } from "./settings/namespace";
116
- export { loadCache, get, set, remove, getAll } from "./settings";
117
- export type { SettingEntry } from "./settings";
117
+ export { loadCache, get, read, readStates, set, remove, getAll, SettingUnreadableError } from "./settings";
118
+ export type { SettingEntry, SettingRead, StoredSettingState } from "./settings";
118
119
  export { SETTINGS, SETTINGS_MAP, registerSettings } from "./settings/defaults";
119
120
  export { validateSettingValue, normalizeSettingValue, getSettingLabel } from "./settings/defaults";
120
121
  export type { SettingDef, SettingKind, SettingOption } from "./settings/defaults";
@@ -163,6 +164,9 @@ export {
163
164
  renderHtmlToPdfWithConfig,
164
165
  renderTemplatePdfPreview,
165
166
  testGotenberg,
167
+ preparePdfImage,
168
+ PDF_IMAGE_MAX_INPUT_BYTES,
169
+ PDF_IMAGE_MAX_BOXES,
166
170
  } from "./pdf";
167
171
  export type {
168
172
  BuildPresetPdfHtmlInput,
@@ -183,6 +187,12 @@ export type {
183
187
  TemplatePdfPreviewError,
184
188
  TemplatePdfPreviewPhase,
185
189
  TemplatePdfPreviewResult,
190
+ PdfImageBox,
191
+ PdfImageVariant,
192
+ PdfImageErrorCode,
193
+ PreparePdfImageInput,
194
+ PreparePdfImageOptions,
195
+ PreparePdfImageResult,
186
196
  } from "./pdf";
187
197
 
188
198
  export { createSyncOpsRoutes, SYNC_OPS_DEAD_LETTER_LIMIT } from "./sync-ops";
@@ -197,7 +207,16 @@ export type {
197
207
 
198
208
  export { latestTopicCursor } from "./topic-cursor";
199
209
  export { readAccountCategoryPolicy, isAccountCategoryAllowed } from "./account-category-policy";
200
- export { AppApprovalError, appApproval, type AppDeviceAdministrator, type AppDeviceEnrollmentNotice } from "./app-approval";
210
+ export {
211
+ AppApprovalError,
212
+ appApproval,
213
+ type AppDeviceAdministrator,
214
+ type AppDeviceEnrollmentNotice,
215
+ type AppApprovalConfig,
216
+ type AppTerminalLogins,
217
+ type AppTerminalLoginInput,
218
+ readAppApprovalConfig,
219
+ } from "./app-approval";
201
220
  export { PwaError, pwaDevices, type PwaDeviceAdministrator, type PwaDevicePairedNotice } from "./pwa-devices";
202
221
  export { legalConsent } from "./legal-consent";
203
222
 
@@ -7,6 +7,7 @@ import { isSafeNotificationTargetHref } from "../../contracts/notification-types
7
7
  import { type BrowserPushSubscription, BrowserPushSubscriptionSchema } from "../../contracts/user-notifications";
8
8
  import { decryptSecret, encryptSecret } from "../secrets";
9
9
  import { coreSettings } from "../settings/api";
10
+ import { SettingUnreadableError } from "../settings/store";
10
11
  import { type NotificationDestination, registerNotificationChannel } from "./channels";
11
12
  import { NOTIFICATION_PREVIEW_LIMIT } from "./preview";
12
13
  import { sendPinnedWebPush } from "./web-push-transport";
@@ -76,10 +77,16 @@ const ensureVapidConfig = (): Promise<{ publicKey: string; privateKey: string }>
76
77
  vapidConfigPromise = sql
77
78
  .begin(async (tx) => {
78
79
  await tx`SELECT pg_advisory_xact_lock(hashtextextended('notifications:web-push-vapid', 0))`;
79
- let [publicKey, privateKey] = await Promise.all([
80
- coreSettings.get<string>("notifications.web_push_public_key"),
81
- coreSettings.get<string>("notifications.web_push_private_key"),
82
- ]);
80
+ const keys = ["notifications.web_push_public_key", "notifications.web_push_private_key"] as const;
81
+ const states = await Promise.all(keys.map((key) => coreSettings.read<string>(key)));
82
+ for (const [index, state] of states.entries()) {
83
+ if (state.stored === "unreadable") {
84
+ const key = keys[index]!;
85
+ console.warn(`[notifications] cannot read setting "${key}"; keeping stored VAPID keys unchanged`);
86
+ throw new SettingUnreadableError(key);
87
+ }
88
+ }
89
+ let [publicKey, privateKey] = states.map((state) => state.value);
83
90
  if (!publicKey || !privateKey) {
84
91
  const generated = webpush.generateVAPIDKeys();
85
92
  publicKey = generated.publicKey;
@@ -240,7 +247,11 @@ unregisterDriver = registerNotificationChannel(browserDriver);
240
247
  export const browserNotifications = {
241
248
  start: async (): Promise<void> => {
242
249
  unregisterDriver ??= registerNotificationChannel(browserDriver);
243
- await ensureWebPushConfigured();
250
+ try {
251
+ await ensureWebPushConfigured();
252
+ } catch (error) {
253
+ if (!(error instanceof SettingUnreadableError)) throw error;
254
+ }
244
255
  },
245
256
  stop: (): void => {
246
257
  unregisterDriver?.();