@k2b/cloud 0.23.0 → 0.24.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 (45) hide show
  1. package/package.json +2 -2
  2. package/src/_internal/capabilities.ts +230 -75
  3. package/src/_internal/define-app.ts +12 -3
  4. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  5. package/src/_internal/page-responses.ts +1 -1
  6. package/src/_internal/registry.ts +78 -3
  7. package/src/ai/chat/blocks.tsx +2 -1
  8. package/src/ai/chat/messages.ts +6 -0
  9. package/src/ai/client/controller.ts +65 -20
  10. package/src/ai/client/transport.ts +5 -5
  11. package/src/ai/code-mode-skill.ts +2 -2
  12. package/src/ai/index.ts +0 -7
  13. package/src/ai/live-events.ts +1 -262
  14. package/src/ai/live.ts +31 -2
  15. package/src/ai/migrate.ts +35 -16
  16. package/src/ai/runtime.ts +1 -6
  17. package/src/ai/solid.ts +0 -5
  18. package/src/api/help.ts +2 -2
  19. package/src/api/pwa-phone.ts +10 -3
  20. package/src/api/search/schemas.ts +34 -0
  21. package/src/api/search.ts +118 -50
  22. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  23. package/src/browser/CloudResourceSearch.tsx +208 -80
  24. package/src/browser/resource-search-messages.ts +15 -2
  25. package/src/browser/search-stream.ts +151 -0
  26. package/src/contracts/capabilities.ts +89 -24
  27. package/src/contracts/capability-compatibility.ts +28 -14
  28. package/src/contracts/file-provider.ts +158 -0
  29. package/src/contracts/index.ts +1 -0
  30. package/src/events/live-engine.ts +11 -7
  31. package/src/events/live.ts +32 -32
  32. package/src/server/help.ts +3 -3
  33. package/src/services/index.ts +1 -1
  34. package/src/services/outbox.ts +29 -115
  35. package/src/services/pdf/markdown.ts +22 -2
  36. package/src/services/pwa-devices.ts +74 -8
  37. package/src/shared/markdown/formula.ts +122 -26
  38. package/src/shared/markdown/index.ts +27 -14
  39. package/src/ssr/PwaLayout.tsx +2 -1
  40. package/src/styles/resource-search.css +19 -0
  41. package/src/ai/client/live-connection.ts +0 -141
  42. package/src/ai/live-messages.ts +0 -45
  43. package/src/ai/live-outbox.ts +0 -102
  44. package/src/ai/live-routes.ts +0 -412
  45. package/src/shared/markdown/extensions/info-blocks.ts +0 -79
@@ -5,29 +5,25 @@ import { logger } from "./logging";
5
5
  * PostgreSQL transactional-outbox dispatcher.
6
6
  *
7
7
  * Domain writers insert a row in their own transaction; this dispatcher claims
8
- * pending rows (`FOR UPDATE SKIP LOCKED`), publishes them, and records
9
- * delivery or an exponential-backoff retry. Rows stay durable until they are
10
- * delivered (or dead after `maxAttempts`, when the table has a `dead_at`
11
- * column), and delivered rows are pruned after a retention period.
8
+ * pending rows (`FOR UPDATE SKIP LOCKED`), publishes them, and deletes a
9
+ * published row or schedules an exponential-backoff retry. Every row in the
10
+ * table is pending: rows stay until they are published.
12
11
  *
13
- * With `sequence`, it works in batches: claims of one outbox (table and
14
- * `where`) take turns, a claim takes rows in sequence order with several rows
15
- * per ordering key, and the dispatcher publishes keys concurrently and the
16
- * rows of one key in order, then completes the batch in one statement. A row
17
- * that never failed is due at once. Claims stay proportional to the batch
18
- * with an index on the `where` columns and `sequence`, and one on the `where`
19
- * columns and `orderBy` limited to `claimed_until IS NOT NULL OR attempts > 0`.
12
+ * It works in batches: claims of one outbox (table and `where`) take turns, a
13
+ * claim takes rows in `sequence` order with several rows per ordering key, and
14
+ * the dispatcher publishes keys concurrently and the rows of one key in order,
15
+ * then completes the batch in one statement. A row that never failed is due at
16
+ * once. Claims stay proportional to the batch with an index on the `where`
17
+ * columns and `sequence`, and one on the `where` columns and `orderBy` limited
18
+ * to `claimed_until IS NOT NULL OR attempts > 0`.
20
19
  *
21
20
  * Required columns: `id uuid`, `attempts int`, `next_attempt_at timestamptz`,
22
- * `claimed_until timestamptz`, `delivered_at timestamptz`, `last_error text`,
23
- * `created_at timestamptz`. `maxAttempts` additionally needs `dead_at`;
24
- * `onDelivered: "delete"` needs no `delivered_at`.
21
+ * `claimed_until timestamptz`, `last_error text`, and the `orderBy` and
22
+ * `sequence` columns.
25
23
  */
26
24
 
27
25
  const DEFAULT_CLAIM_MS = 30_000;
28
26
  const DEFAULT_BATCH_SIZE = 100;
29
- const DELIVERED_RETENTION = "7 days";
30
- const DEAD_RETENTION = "30 days";
31
27
  const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
32
28
 
33
29
  export type OutboxRow = {
@@ -43,20 +39,14 @@ export type PgOutboxConfig<Row extends OutboxRow> = {
43
39
  publish: (row: Row) => Promise<unknown>;
44
40
  reconcileIntervalMs: number;
45
41
  /**
46
- * Column whose rows must be delivered in insertion order. A pending earlier
47
- * row with the same value blocks later ones until it is delivered or dead.
48
- * With `sequence`, a value waits while any of its rows is claimed or waits
49
- * for a retry.
42
+ * Column whose rows are published in `sequence` order. A value waits while
43
+ * any of its rows is claimed or waits for a retry.
50
44
  */
51
- orderBy?: Extract<keyof Row, string>;
45
+ orderBy: Extract<keyof Row, string>;
46
+ /** Insertion-order column that orders claims. */
47
+ sequence: string;
52
48
  /** Fixed column values every claimed row matches, e.g. `{ kind: "live", app_id: "contacts" }`. */
53
49
  where?: Readonly<Record<string, string>>;
54
- /** Insertion-order column that orders claims instead of `(created_at, id)`, and enables batches (see above). */
55
- sequence?: string;
56
- /** Delete a published row instead of setting `delivered_at`; every row in the table is then pending. */
57
- onDelivered?: "delete";
58
- /** Mark rows dead after this many failed attempts (requires `dead_at`). */
59
- maxAttempts?: number;
60
50
  claimMs?: number;
61
51
  batchSize?: number;
62
52
  };
@@ -79,23 +69,15 @@ const identifier = (name: string, label: string): ReturnType<typeof sql.unsafe>
79
69
 
80
70
  export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row>): PgOutbox<Row> => {
81
71
  const table = identifier(config.table, "table");
82
- const orderBy = config.orderBy ? identifier(config.orderBy, "ordering column") : null;
83
- const maxAttempts = config.maxAttempts ?? null;
72
+ const runKey = config.orderBy;
73
+ const runColumn = identifier(runKey, "ordering column");
74
+ const sequence = identifier(config.sequence, "sequence column");
84
75
  const claimMs = config.claimMs ?? DEFAULT_CLAIM_MS;
85
76
  const batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
86
77
  const log = logger(config.name);
87
- const sequence = config.sequence ? identifier(config.sequence, "sequence column") : null;
88
- const deleteDelivered = config.onDelivered === "delete";
89
- const notDead = maxAttempts === null ? sql`` : sql`AND dead_at IS NULL`;
90
- const earlierNotDead = maxAttempts === null ? sql`` : sql`AND earlier.dead_at IS NULL`;
91
- const pending = deleteDelivered ? sql`` : sql`AND delivered_at IS NULL`;
92
- const earlierPending = deleteDelivered ? sql`` : sql`AND earlier.delivered_at IS NULL`;
93
78
  const filters = Object.entries(config.where ?? {}).map(([column, value]) => ({ column: identifier(column, "filter column"), value }));
94
- const matches = (alias: "current" | "earlier" | "busy") =>
79
+ const matches = (alias: "current" | "busy") =>
95
80
  filters.reduce((fragment, { column, value }) => sql`${fragment} AND ${sql.unsafe(alias)}.${column} = ${value}`, sql``);
96
- // Rows without an ordering column are independent: each is a key of its own.
97
- const runKey = config.orderBy ?? "id";
98
- const runColumn = identifier(runKey, "ordering column");
99
81
  const claimLock = `${config.table}:${JSON.stringify(config.where ?? {})}`;
100
82
 
101
83
  /**
@@ -104,8 +86,9 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
104
86
  * delays retries: it is the writer's transaction start, so a fresh row that
105
87
  * committed later can carry an earlier one, or one after this claim began.
106
88
  */
107
- const claimBatch = (cap: number, order: ReturnType<typeof sql.unsafe>) =>
89
+ const claim: PgOutbox<Row>["claim"] = (limit = batchSize) =>
108
90
  sql.begin(async (tx) => {
91
+ const cap = Math.min(Math.max(limit, 1), batchSize);
109
92
  // Claims take turns: a concurrent claim would skip this one's locked rows and take later rows of the same keys.
110
93
  // A claim that outlives its claim period is worthless, so neither a slow statement nor a vanished client holds the turn longer.
111
94
  await tx`
@@ -121,20 +104,16 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
121
104
  FROM ${table} current
122
105
  WHERE (current.attempts = 0 OR current.next_attempt_at <= now())
123
106
  AND (current.claimed_until IS NULL OR current.claimed_until <= now())
124
- ${pending}
125
- ${notDead}
126
107
  ${matches("current")}
127
108
  AND NOT EXISTS (
128
109
  SELECT
129
110
  FROM ${table} busy
130
111
  WHERE busy.${runColumn} = current.${runColumn}
131
112
  AND (busy.claimed_until > now() OR (busy.attempts > 0 AND busy.next_attempt_at > now()))
132
- ${pending}
133
- ${notDead}
134
113
  ${matches("busy")}
135
114
  OFFSET 0
136
115
  )
137
- ORDER BY current.${order}
116
+ ORDER BY current.${sequence}
138
117
  LIMIT ${cap}
139
118
  FOR UPDATE SKIP LOCKED
140
119
  ), claimed AS (
@@ -144,63 +123,21 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
144
123
  WHERE outbox.id = candidates.id
145
124
  RETURNING outbox.*
146
125
  )
147
- SELECT * FROM claimed ORDER BY ${order}
126
+ SELECT * FROM claimed ORDER BY ${sequence}
148
127
  `;
149
128
  });
150
129
 
151
- const claim: PgOutbox<Row>["claim"] = async (limit = batchSize) => {
152
- const cap = Math.min(Math.max(limit, 1), batchSize);
153
- if (sequence !== null) return claimBatch(cap, sequence);
154
- const ordered =
155
- orderBy === null
156
- ? sql``
157
- : sql`
158
- AND NOT EXISTS (
159
- SELECT 1
160
- FROM ${table} earlier
161
- WHERE earlier.${orderBy} = current.${orderBy}
162
- ${earlierPending}
163
- ${earlierNotDead}
164
- ${matches("earlier")}
165
- AND (earlier.created_at, earlier.id) < (current.created_at, current.id)
166
- )
167
- `;
168
- // One statement: the CTE claim and the update commit atomically.
169
- return sql<Row[]>`
170
- WITH candidates AS MATERIALIZED (
171
- SELECT current.id
172
- FROM ${table} current
173
- WHERE current.next_attempt_at <= now()
174
- AND (current.claimed_until IS NULL OR current.claimed_until <= now())
175
- ${pending}
176
- ${notDead}
177
- ${matches("current")}
178
- ${ordered}
179
- ORDER BY current.next_attempt_at, current.created_at, current.id
180
- LIMIT ${cap}
181
- FOR UPDATE SKIP LOCKED
182
- )
183
- UPDATE ${table} outbox
184
- SET claimed_until = now() + (${claimMs} * interval '1 millisecond')
185
- FROM candidates
186
- WHERE outbox.id = candidates.id
187
- RETURNING outbox.*
188
- `;
189
- };
190
-
191
130
  const retryLater = async (row: Row, error: unknown) => {
192
131
  const attempts = row.attempts + 1;
193
132
  const message = error instanceof Error ? error.message : String(error);
194
133
  const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
195
- const dead = maxAttempts === null ? sql`` : sql`dead_at = CASE WHEN ${attempts} >= ${maxAttempts} THEN now() ELSE dead_at END,`;
196
134
  await sql`
197
135
  UPDATE ${table}
198
136
  SET attempts = ${attempts},
199
137
  next_attempt_at = now() + (${delaySeconds} * interval '1 second'),
200
138
  claimed_until = NULL,
201
- ${dead}
202
139
  last_error = ${message.slice(0, 1_000)}
203
- WHERE id = ${row.id}::uuid ${pending} ${notDead} AND attempts = ${row.attempts}
140
+ WHERE id = ${row.id}::uuid AND attempts = ${row.attempts}
204
141
  `;
205
142
  log.warn("Outbox delivery failed", { outboxId: row.id, attempts, error: message });
206
143
  };
@@ -208,15 +145,7 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
208
145
  const dispatch: PgOutbox<Row>["dispatch"] = async (row, publish = config.publish) => {
209
146
  try {
210
147
  await publish(row);
211
- if (deleteDelivered) {
212
- await sql`DELETE FROM ${table} WHERE id = ${row.id}::uuid AND attempts = ${row.attempts}`;
213
- return;
214
- }
215
- await sql`
216
- UPDATE ${table}
217
- SET delivered_at = now(), claimed_until = NULL, last_error = NULL
218
- WHERE id = ${row.id}::uuid ${pending} ${notDead} AND attempts = ${row.attempts}
219
- `;
148
+ await sql`DELETE FROM ${table} WHERE id = ${row.id}::uuid AND attempts = ${row.attempts}`;
220
149
  } catch (error) {
221
150
  await retryLater(row, error);
222
151
  }
@@ -241,12 +170,7 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
241
170
  }
242
171
  }),
243
172
  );
244
- if (delivered.length > 0) {
245
- const ids = sql.array(delivered, "uuid");
246
- await (deleteDelivered
247
- ? sql`DELETE FROM ${table} WHERE id = ANY(${ids})`
248
- : sql`UPDATE ${table} SET delivered_at = now(), claimed_until = NULL, last_error = NULL WHERE id = ANY(${ids})`);
249
- }
173
+ if (delivered.length > 0) await sql`DELETE FROM ${table} WHERE id = ANY(${sql.array(delivered, "uuid")})`;
250
174
  for (const result of results) if (result.status === "rejected") throw result.reason;
251
175
  };
252
176
 
@@ -257,22 +181,12 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
257
181
  reconcileRequested = true;
258
182
  if (activeReconcile) return activeReconcile;
259
183
  activeReconcile = (async () => {
260
- if (!deleteDelivered) {
261
- const deadExpired = maxAttempts === null ? sql`` : sql`OR dead_at < now() - ${DEAD_RETENTION}::interval`;
262
- await sql`
263
- DELETE FROM ${table}
264
- WHERE delivered_at < now() - ${DELIVERED_RETENTION}::interval
265
- ${deadExpired}
266
- `;
267
- }
268
184
  let processed = 0;
269
185
  let rows: Row[];
270
186
  do {
271
187
  reconcileRequested = false;
272
188
  rows = await claim();
273
- if (sequence !== null) await dispatchBatch(rows);
274
- // Sequential: across keys, the claim order is the publish order.
275
- else for (const row of rows) await dispatch(row);
189
+ await dispatchBatch(rows);
276
190
  processed += rows.length;
277
191
  } while (reconcileRequested || rows.length > 0);
278
192
  return processed;
@@ -1,3 +1,4 @@
1
+ import { markdownInfoBlocks } from "@k2b/ui";
1
2
  import { Marked, Renderer, type Tokens } from "marked";
2
3
  import postcss from "postcss";
3
4
  import {
@@ -122,6 +123,25 @@ hr { border: 0; border-top: 1px solid #d1d5db; margin: .9em 0; }
122
123
  `,
123
124
  };
124
125
 
126
+ /**
127
+ * Markdown info blocks print as the calm NoticeCard: a light tint per tone, no
128
+ * border, and the preset's own text colour, so they stay readable on paper.
129
+ * The tints are a step stronger than on screen because paper and printers wash
130
+ * them out. Every preset and application document shares them.
131
+ */
132
+ const INFO_BLOCK_PRINT_CSS = `
133
+ .k2b-sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
134
+ .k2b-notice-card { margin: 0 0 1em; padding: .85em 1.05em; border-radius: .8em; background: var(--notice-tint); break-inside: avoid; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
135
+ .k2b-notice-card[data-tone="neutral"] { --notice-tint: #f1f2f4; }
136
+ .k2b-notice-card[data-tone="info"] { --notice-tint: #e9f1fe; }
137
+ .k2b-notice-card[data-tone="success"] { --notice-tint: #e6f6ee; }
138
+ .k2b-notice-card[data-tone="warning"] { --notice-tint: #fcf2da; }
139
+ .k2b-notice-card[data-tone="danger"] { --notice-tint: #fcebeb; }
140
+ .k2b-notice-card__title { margin: 0 0 .25em; font-weight: 600; }
141
+ .k2b-notice-card__body > :first-child { margin-top: 0; }
142
+ .k2b-notice-card__body > :last-child { margin-bottom: 0; }
143
+ `;
144
+
125
145
  const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
126
146
 
127
147
  const escapeHtml = (value: string): string =>
@@ -156,7 +176,7 @@ const renderMarkdown = (source: string): string => {
156
176
  return `<a href="${escapeHtml(safeHref)}"${titleAttribute}>${body}</a>`;
157
177
  };
158
178
 
159
- const parser = new Marked({ breaks: true, gfm: true, renderer });
179
+ const parser = new Marked({ breaks: true, gfm: true, renderer }, markdownInfoBlocks());
160
180
  try {
161
181
  return parser.parse(source, { async: false }) as string;
162
182
  } catch (cause) {
@@ -216,7 +236,7 @@ export const buildPresetPdfHtml = (input: BuildPresetPdfHtmlInput): string => {
216
236
  const suppliedCustomCss = input.customCss?.trim() ?? "";
217
237
  const customCss = suppliedCustomCss ? validateCustomCss(input.customCss ?? "") : "";
218
238
  const presetCss = templateId ? TEMPLATE_CSS[templateId] : customCss ? "" : TEMPLATE_CSS.document;
219
- const baseCss = [presetCss, input.css ? styleText(input.css) : ""].filter(Boolean).join("\n");
239
+ const baseCss = [presetCss, INFO_BLOCK_PRINT_CSS, input.css ? styleText(input.css) : ""].filter(Boolean).join("\n");
220
240
  const stylesheet = `${baseCss}${baseCss && customCss ? `\n/* Custom CSS overrides */\n` : ""}${customCss}`;
221
241
  return `<!doctype html>
222
242
  <html lang="en">
@@ -4,6 +4,7 @@ import type { Context } from "hono";
4
4
  import { PWA_LIMITS as limits, type PwaDeviceView, type PwaErrorCode, type PwaPairingState, type PwaPlatform } from "../contracts/pwa";
5
5
  import { isAccountCategoryAllowed } from "./account-category-policy";
6
6
  import { audit } from "./audit";
7
+ import { logger } from "./logging";
7
8
  import { pairingSecret } from "./pairing-secret";
8
9
  import { type AuthenticatedSession, session } from "./session";
9
10
  import { requireRecentWebSession } from "./session/recent";
@@ -32,6 +33,19 @@ export type PwaWebActor = { userId: string; sid: string };
32
33
  /** Administration of another account's phones needs current admin authority. */
33
34
  export type PwaDeviceAdministrator = { userId: string; admin: boolean };
34
35
  export type PwaRevocationReason = "user" | "unpaired" | "admin" | "replaced" | "account_expired";
36
+ /**
37
+ * The "new phone paired" notice, sent once per device after its pairing committed. It carries no
38
+ * pairing code, secret or key. `locale` is the completing request's locale, if one was stored.
39
+ */
40
+ export type PwaDevicePairedNotice = {
41
+ deviceId: string;
42
+ userId: string;
43
+ name: string;
44
+ platform: PwaPlatform;
45
+ pairedAt: string;
46
+ locale: string | null;
47
+ };
48
+ export type PwaDeviceNotifier = (notice: PwaDevicePairedNotice) => Promise<unknown>;
35
49
 
36
50
  type PairingRow = {
37
51
  id: string;
@@ -59,6 +73,8 @@ type DeviceRow = {
59
73
  created_at: Date;
60
74
  last_used_at: Date;
61
75
  revoked_at: Date | null;
76
+ locale: string | null;
77
+ notified_at: Date | null;
62
78
  };
63
79
  type UserRow = {
64
80
  id: string;
@@ -70,6 +86,7 @@ type UserRow = {
70
86
  account_expires: Date | null;
71
87
  };
72
88
 
89
+ const log = logger("pwa-devices");
73
90
  const iso = (value: Date | string) => new Date(value).toISOString();
74
91
  const passed = (value: Date | string) => new Date(value).getTime() <= Date.now();
75
92
  const DEVICE_KEY = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([A-Za-z0-9_-]{43})$/;
@@ -123,6 +140,30 @@ const initiatorValid = async (tx: SQL, pairing: PairingRow): Promise<boolean> =>
123
140
  };
124
141
 
125
142
  const accountExpired = (user: UserRow) => Boolean(user.account_expires && passed(user.account_expires));
143
+
144
+ /** `name` is the one the owner confirmed; the phone can rename itself once it is paired. */
145
+ const pairedNotice = (device: DeviceRow, name: string): PwaDevicePairedNotice => ({
146
+ deviceId: device.id,
147
+ userId: device.user_id,
148
+ name,
149
+ platform: device.platform,
150
+ pairedAt: iso(device.created_at),
151
+ locale: device.locale,
152
+ });
153
+
154
+ /**
155
+ * Sends the "new phone paired" notice and marks it sent. The notification owner deduplicates by
156
+ * device id, so a crash between the two sends nothing twice. A failure leaves it to maintenance.
157
+ */
158
+ const announce = async (notify: PwaDeviceNotifier, notice: PwaDevicePairedNotice) => {
159
+ try {
160
+ await notify(notice);
161
+ await sql`UPDATE auth.pwa_devices SET notified_at = now() WHERE id = ${notice.deviceId}::uuid AND notified_at IS NULL`;
162
+ } catch (error) {
163
+ log.warn("App device notice failed", { error: error instanceof Error ? error.name : "UnknownError" });
164
+ }
165
+ };
166
+
126
167
  const legalAccepted = async (tx: SQL, userId: string) =>
127
168
  (await tx`SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${userId}::uuid`).length > 0;
128
169
 
@@ -402,7 +443,9 @@ export const createPwaDeviceService = () => {
402
443
 
403
444
  /**
404
445
  * Finishes a confirmed pairing in the app. `webUserId` is the owner of a valid web session in
405
- * the same cookie jar; `appSession` is a valid app session presented with the request.
446
+ * the same cookie jar; `appSession` is a valid app session presented with the request. The
447
+ * "new phone paired" notice goes out before the phone receives its credentials, so the phone
448
+ * can neither turn it off, rename itself in it, nor sign out before it is sent.
406
449
  */
407
450
  completePairing: async (
408
451
  c: Context,
@@ -411,6 +454,9 @@ export const createPwaDeviceService = () => {
411
454
  deviceKey: string | null | undefined;
412
455
  appSession: AuthenticatedSession | null;
413
456
  webUserId: string | null;
457
+ /** The request locale, kept for the "new phone paired" notice. */
458
+ locale?: string;
459
+ notify: PwaDeviceNotifier;
414
460
  },
415
461
  ): Promise<PwaCompletion> => {
416
462
  if (!input.completionSecret) return fail("EXPIRED", 410);
@@ -428,7 +474,7 @@ export const createPwaDeviceService = () => {
428
474
  if (snapshot.state !== "confirmed" && snapshot.state !== "completed") return fail("EXPIRED", 410);
429
475
  // The device key of this app, read before the locks; it is rechecked under them.
430
476
  const presented = await presentedDevice(input.deviceKey);
431
- type Step = { kind: "already" } | { kind: "issued"; deviceKey: string };
477
+ type Step = { kind: "already" } | { kind: "issued"; deviceKey: string; notice: PwaDevicePairedNotice | null };
432
478
  const { value, token } = await session.issueInTransaction<Step>(c, async (tx, issue) => {
433
479
  const user = await lockUser(tx, snapshot.user_id);
434
480
  const [p] = await tx<PairingRow[]>`SELECT * FROM auth.pwa_pairings WHERE id = ${snapshot.id}::uuid FOR UPDATE`;
@@ -447,7 +493,9 @@ export const createPwaDeviceService = () => {
447
493
  const deviceKey = await newKey(tx, device.id, "rotate");
448
494
  await issue(user.id, appSession(device.id));
449
495
  await record(tx, "device.recover", { type: "pwa_device", id: device.id }, user.id);
450
- return { kind: "issued", deviceKey };
496
+ // The first answer may have been lost before its notice went out.
497
+ const notice = device.notified_at ? null : pairedNotice(device, p.device_name ?? device.name);
498
+ return { kind: "issued", deviceKey, notice };
451
499
  }
452
500
  if (p.state !== "confirmed" || !p.platform || !p.device_name) return fail("EXPIRED", 410);
453
501
  if (!(await initiatorValid(tx, p))) return fail("EXPIRED", 410);
@@ -466,16 +514,21 @@ export const createPwaDeviceService = () => {
466
514
  if ((active?.count ?? 0) >= limits.devicesPerAccount) return fail("LIMIT_REACHED", 429);
467
515
  const deviceId = crypto.randomUUID();
468
516
  const secret = pairingSecret.create();
469
- await tx`INSERT INTO auth.pwa_devices (id, user_id, name, platform, auth_epoch, secret_hash, rotated_at)
470
- VALUES (${deviceId}::uuid, ${user.id}::uuid, ${p.device_name}, ${p.platform}, ${user.auth_epoch}, ${pairingSecret.hash(secret)}, now())`;
517
+ // An unusually long locale tag is dropped; the notice then uses the operator's locale.
518
+ const locale = input.locale && input.locale.length <= 35 ? input.locale : null;
519
+ const [device] = await tx<DeviceRow[]>`
520
+ INSERT INTO auth.pwa_devices (id, user_id, name, platform, auth_epoch, secret_hash, rotated_at, locale)
521
+ VALUES (${deviceId}::uuid, ${user.id}::uuid, ${p.device_name}, ${p.platform}, ${user.auth_epoch}, ${pairingSecret.hash(secret)}, now(), ${locale})
522
+ RETURNING *`;
471
523
  await issue(user.id, appSession(deviceId));
472
524
  await tx`UPDATE auth.pwa_pairings SET state = 'completed', device_id = ${deviceId}::uuid WHERE id = ${p.id}::uuid`;
473
525
  await record(tx, "device.enroll", { type: "pwa_device", id: deviceId }, user.id, { platform: p.platform, pairingId: p.id });
474
- return { kind: "issued", deviceKey: `${deviceId}.${secret}` };
526
+ return { kind: "issued", deviceKey: `${deviceId}.${secret}`, notice: pairedNotice(device!, p.device_name) };
475
527
  });
476
528
  const platform = snapshot.platform ?? "other";
477
529
  if (value.kind === "already") return { state: "paired", credentials: null, platform };
478
530
  if (!token) throw new Error("App session was not issued");
531
+ if (value.notice) await announce(input.notify, value.notice);
479
532
  return { state: "paired", credentials: { deviceKey: value.deviceKey, sessionToken: token }, platform };
480
533
  },
481
534
 
@@ -543,8 +596,11 @@ export const createPwaDeviceService = () => {
543
596
 
544
597
  // ---------- Maintenance ----------
545
598
 
546
- /** Bounded to 100 rows per step; runs every minute from Core's scheduler. */
547
- maintain: async (signal?: AbortSignal) => {
599
+ /**
600
+ * Bounded to 100 rows per step; runs every minute from Core's scheduler. `notify` retries the
601
+ * "new phone paired" notices that completion could not send.
602
+ */
603
+ maintain: async (notify: PwaDeviceNotifier, signal?: AbortSignal) => {
548
604
  await sql`DELETE FROM auth.pwa_pairings WHERE id IN (
549
605
  SELECT id FROM auth.pwa_pairings WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
550
606
  signal?.throwIfAborted();
@@ -567,6 +623,16 @@ export const createPwaDeviceService = () => {
567
623
  WHERE (d.revoked_at < now() - interval '30 days' OR d.rotated_at < now() - ${limits.idleDays + 30} * interval '1 day')
568
624
  AND NOT EXISTS (SELECT 1 FROM auth.session_families f WHERE f.pwa_device_id = d.id)
569
625
  ORDER BY d.id LIMIT 100 FOR UPDATE SKIP LOCKED)`;
626
+ signal?.throwIfAborted();
627
+ // Every pairing is announced, also of a phone removed since: it had access to the account.
628
+ const pending = await sql<DeviceRow[]>`
629
+ SELECT * FROM auth.pwa_devices WHERE notified_at IS NULL ORDER BY created_at, id LIMIT 100
630
+ `;
631
+ for (const row of pending) {
632
+ signal?.throwIfAborted();
633
+ // A failing notice stays pending for the next run and does not hold back the others.
634
+ await announce(notify, pairedNotice(row, row.name));
635
+ }
570
636
  },
571
637
  };
572
638
  };