@unbrained/pm-web 2026.9.26 → 2026.10.5

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.
package/public/src/sw.ts CHANGED
@@ -24,6 +24,11 @@ const BUILD_TIMESTAMP: string = '__BUILD_TIME__';
24
24
  const CACHE_NAME = 'pm-web-' + (BUILD_TIMESTAMP !== '__BUILD_TIME__' ? BUILD_TIMESTAMP : Date.now().toString(36));
25
25
  const MUTATION_DB = 'pm-web-offline';
26
26
  const MUTATION_STORE = 'mutations';
27
+ // The signed-in account the page broadcasts lives in its own store so the
28
+ // mutation records and the session can be written and read independently,
29
+ // exactly like a real browser would keep them.
30
+ const SESSION_STORE = 'session';
31
+ const SESSION_KEY = 'current';
27
32
 
28
33
  const STATIC_ASSETS: readonly string[] = [
29
34
  '/',
@@ -42,6 +47,7 @@ const STATIC_ASSETS: readonly string[] = [
42
47
  '/src/i18n.js',
43
48
  '/src/i18n/de.json',
44
49
  '/src/i18n/en.json',
50
+ '/src/offline-recovery.js',
45
51
  '/src/state.js',
46
52
  '/src/theme.js',
47
53
  '/src/types.js',
@@ -113,6 +119,18 @@ interface QueuedMutation {
113
119
  path: string;
114
120
  body: string | null;
115
121
  timestamp: number;
122
+ /** Account that queued the mutation. Absent on legacy records. */
123
+ ownerId?: string | null;
124
+ /** Workspace (project id) the queued path targets. Absent on legacy records. */
125
+ workspace?: string | null;
126
+ /** Server-enforced exactly-once identity for the replay. */
127
+ idempotencyKey?: string;
128
+ /** Durable recovery state; absence on legacy records means queued. */
129
+ state?: 'queued' | 'needs-attention';
130
+ /** Terminal replay problem requiring owner action. */
131
+ attentionReason?: 'outcome-unknown' | 'repeated-5xx';
132
+ /** Consecutive server failures survive worker restarts. */
133
+ serverFailures?: number;
116
134
  }
117
135
 
118
136
  interface StoredMutation {
@@ -120,6 +138,73 @@ interface StoredMutation {
120
138
  path: string;
121
139
  body: string | null;
122
140
  timestamp: number;
141
+ /** Account that queued the mutation; `null` when no session was known. */
142
+ ownerId: string | null;
143
+ /** Workspace (project id) the queued path targets; `null` when account-level. */
144
+ workspace: string | null;
145
+ /** Server-enforced exactly-once identity for the replay. */
146
+ idempotencyKey: string;
147
+ }
148
+
149
+ /** The signed-in account as persisted in the `session` store. */
150
+ interface StoredSession {
151
+ key: string;
152
+ userId: string | null;
153
+ }
154
+
155
+ /** Shape of the `/api/auth/me` response body the flush bootstrap reads. */
156
+ interface MeBody {
157
+ user?: { id?: string };
158
+ }
159
+
160
+ /** Why one queued mutation was refused during a flush. */
161
+ type ReplayBlockReason = 'no-session' | 'unknown-owner' | 'owner-mismatch' | 'auth-route'
162
+ | 'outcome-unknown' | 'repeated-5xx' | 'workspace-order';
163
+
164
+ /** A queued mutation a flush refused, surfaced to the page for explicit recovery. */
165
+ interface BlockedMutation {
166
+ id: number;
167
+ method: string;
168
+ path: string;
169
+ ownerId: string | null;
170
+ workspace: string | null;
171
+ reason: ReplayBlockReason;
172
+ }
173
+
174
+ /**
175
+ * Extract the workspace (project id) a queued API path targets, or `null` for
176
+ * account-level mutations.
177
+ *
178
+ * Every project-scoped mutation the SPA issues lives under
179
+ * `/projects/<projectId>/…`, so the first path segment after `/projects/` is
180
+ * the workspace the record is bound to. The value is stamped at queue time
181
+ * and replayed verbatim in MUTATIONS_BLOCKED reports, so an operator can see
182
+ * which workspace a stranded record belongs to without re-deriving it.
183
+ *
184
+ * @param path - The queued API path (relative to `/api`).
185
+ * @returns The project id the path targets, or `null` when the path is account-level.
186
+ */
187
+ function workspaceFromPath(path: string): string | null {
188
+ const match = /^\/projects\/([^/]+)/.exec(path);
189
+ return match ? match[1] : null;
190
+ }
191
+
192
+ /**
193
+ * Generate a fresh idempotency key for one queued mutation.
194
+ *
195
+ * The key is a random UUID-shaped string so a replayed record can be
196
+ * deduplicated server-side even when the response to the first attempt was
197
+ * lost after the server committed. It is generated before the first attempt and
198
+ * persisted with the record so every retry of the same record reuses the
199
+ * same key and a genuinely different mutation never collides with it.
200
+ *
201
+ * @returns A random 128-bit key formatted as a UUID string.
202
+ */
203
+ function newIdempotencyKey(): string {
204
+ const bytes = new Uint8Array(16);
205
+ crypto.getRandomValues(bytes);
206
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
207
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
123
208
  }
124
209
 
125
210
  // Minimal Background Sync event typing. The `WebWorker` lib does not ship
@@ -133,13 +218,18 @@ interface SyncEvent extends ExtendableEvent {
133
218
  * index exist. Resolves with the ready database handle. */
134
219
  function openMutationDB(): Promise<IDBDatabase> {
135
220
  return new Promise((resolve, reject) => {
136
- const request = indexedDB.open(MUTATION_DB, 1);
221
+ const request = indexedDB.open(MUTATION_DB, 2);
137
222
  request.onupgradeneeded = () => {
138
223
  const db = request.result;
139
224
  if (!db.objectStoreNames.contains(MUTATION_STORE)) {
140
225
  const store = db.createObjectStore(MUTATION_STORE, { keyPath: 'id', autoIncrement: true });
141
226
  store.createIndex('timestamp', 'timestamp', { unique: false });
142
227
  }
228
+ // Version 2 added the session store; an existing version-1 database is
229
+ // upgraded in place so a returning browser keeps its queued mutations.
230
+ if (!db.objectStoreNames.contains(SESSION_STORE)) {
231
+ db.createObjectStore(SESSION_STORE, { keyPath: 'key' });
232
+ }
143
233
  };
144
234
  request.onsuccess = () => resolve(request.result);
145
235
  request.onerror = () => reject(request.error);
@@ -160,18 +250,78 @@ function transactionDone(tx: IDBTransaction): Promise<void> {
160
250
  });
161
251
  }
162
252
 
253
+ /**
254
+ * Read the account the page last broadcast as signed in, or `null` when no
255
+ * session is known.
256
+ *
257
+ * The session lives in its own IndexedDB store so it survives service-worker
258
+ * restarts: a worker can be killed at any time, and a mutation queued after a
259
+ * restart must still be stamped with the account that is signed in, not with
260
+ * whatever in-memory state a freshly booted worker happens to have.
261
+ *
262
+ * @returns The persisted user id, or `null` when nothing was broadcast or the
263
+ * store cannot be read (both mean ownership is unknown, never guessed).
264
+ */
265
+ async function readSession(): Promise<string | null> {
266
+ try {
267
+ const db = await openMutationDB();
268
+ const tx = db.transaction(SESSION_STORE, 'readonly');
269
+ const store = tx.objectStore(SESSION_STORE);
270
+ const session = await new Promise<StoredSession | undefined>((resolve, reject) => {
271
+ const request = store.get(SESSION_KEY);
272
+ request.onsuccess = () => resolve(request.result as StoredSession | undefined);
273
+ request.onerror = () => reject(request.error);
274
+ });
275
+ return session?.userId ?? null;
276
+ } catch {
277
+ return null;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Persist the account the page reports as signed in (or `null` on logout).
283
+ *
284
+ * Called from the AUTH_SESSION message so every later queueMutation stamps its
285
+ * records with this account. A storage failure leaves the previous value in
286
+ * place rather than silently clearing ownership.
287
+ *
288
+ * @param userId - The signed-in user id, or `null` when nobody is signed in.
289
+ */
290
+ async function saveSession(userId: string | null): Promise<void> {
291
+ const db = await openMutationDB();
292
+ const tx = db.transaction(SESSION_STORE, 'readwrite');
293
+ tx.objectStore(SESSION_STORE).put({ key: SESSION_KEY, userId });
294
+ await transactionDone(tx);
295
+ }
296
+
163
297
  /**
164
298
  * Queue a mutation for later replay. Returns `true` only when the mutation has
165
299
  * been durably persisted to IndexedDB; `false` when persistence failed so the
166
300
  * caller can respond with an explicit error instead of claiming it was queued.
301
+ *
302
+ * The record is durably bound to the account the page last broadcast as signed
303
+ * in and to the workspace its path targets, and carries a fresh idempotency
304
+ * key, so a flush can never replay it under a different signed-in user and a
305
+ * retried record can never be applied twice server-side. When no session is
306
+ * known the record is still queued, with `ownerId: null`: ownership is
307
+ * recorded as unknown and the record is surfaced for explicit recovery
308
+ * instead of being guessed (and replayed under whoever happens to be signed
309
+ * in next).
310
+ *
311
+ * @param originatingAccount - Identity captured before the first attempt;
312
+ * direct queue calls without an attempt read the current stored session.
167
313
  */
168
314
  async function queueMutation(
169
315
  method: string,
170
316
  path: string,
171
317
  body: unknown,
318
+ idempotencyKey = newIdempotencyKey(),
319
+ originatingAccount?: string | null,
172
320
  ): Promise<boolean> {
321
+ if (/^\/auth(?:\/|\?|$)/i.test(path)) return false;
173
322
  try {
174
323
  const db = await openMutationDB();
324
+ const ownerId = originatingAccount === undefined ? await readSession() : originatingAccount;
175
325
  const tx = db.transaction(MUTATION_STORE, 'readwrite');
176
326
  const store = tx.objectStore(MUTATION_STORE);
177
327
  const record: StoredMutation = {
@@ -179,6 +329,9 @@ async function queueMutation(
179
329
  path,
180
330
  body: body !== undefined ? JSON.stringify(body) : null,
181
331
  timestamp: Date.now(),
332
+ ownerId,
333
+ workspace: workspaceFromPath(path),
334
+ idempotencyKey,
182
335
  };
183
336
  store.add(record);
184
337
  // Await the transaction commit (not just the request dispatch) so the
@@ -240,22 +393,66 @@ async function clearMutation(id: number): Promise<boolean> {
240
393
  }
241
394
  }
242
395
 
243
- /** Replay queued mutations to the API in order, removing each on success and
244
- * stopping at the first failure so it can retry later, then post-message the
396
+ /**
397
+ * Decide whether one queued mutation may be replayed under the current
398
+ * session, and if not, why.
399
+ *
400
+ * The three refusals are deliberately distinct so a surfaced report tells the
401
+ * user what to do: `no-session` means nobody is signed in (log in and flush
402
+ * again), `unknown-owner` means the record predates ownership tracking (adopt
403
+ * it explicitly), and `owner-mismatch` means the record belongs to a different
404
+ * account and must wait for that account — it is never reassigned silently.
405
+ *
406
+ * @param mut - The queued mutation as IndexedDB returns it.
407
+ * @param currentUserId - The account `/api/auth/me` reports as signed in, or
408
+ * `null` when nobody is.
409
+ * @returns The refusal reason, or `null` when the record may be replayed.
410
+ */
411
+ function replayBlockReason(
412
+ mut: QueuedMutation,
413
+ currentUserId: string | null,
414
+ ): ReplayBlockReason | null {
415
+ if (/^\/auth(?:\/|\?|$)/i.test(mut.path)) return 'auth-route';
416
+ if (currentUserId === null) return 'no-session';
417
+ // Legacy records queued before ownership tracking have no ownerId at all.
418
+ if (mut.ownerId === undefined || mut.ownerId === null) return 'unknown-owner';
419
+ if (mut.ownerId !== currentUserId) return 'owner-mismatch';
420
+ return null;
421
+ }
422
+
423
+ /** Replay each workspace in FIFO order, removing successes, then post-message the
245
424
  * connected clients with how many were replayed or remain. A storage read
246
425
  * failure is never treated as a drained queue: the initial read failure
247
426
  * aborts the flush, and a final read failure reports a partial result rather
248
- * than claiming all mutations were replayed. */
249
- async function flushMutationQueue(): Promise<void> {
427
+ * than claiming all mutations were replayed.
428
+ *
429
+ * Identity is authoritative from the server: `/api/auth/me` names the account
430
+ * that is signed in, and only records that account queued are replayed.
431
+ * Records owned by a different account, records whose ownership is unknown
432
+ * (legacy, or queued with no known session), and every record while logged
433
+ * out are never replayed and never deleted — they are kept and surfaced via
434
+ * MUTATIONS_BLOCKED so the page can offer explicit recovery. An owned failed
435
+ * or attention record blocks only later records in its workspace. Account-level
436
+ * work is a barrier across workspaces: it cannot overtake earlier unresolved
437
+ * owned work, and unresolved account-level work blocks all later owned work.
438
+ * Different owners have separate ordering. Replayed records carry their key,
439
+ * so a retry after an ambiguous (lost) response is applied exactly once
440
+ * server-side. */
441
+ async function replayQueuedMutations(): Promise<void> {
250
442
  const read = await getQueuedMutations();
251
443
  if (!read.ok) {
252
444
  console.warn('Failed to read mutation queue:', read.error);
253
445
  return;
254
446
  }
255
447
  const mutations = read.mutations;
256
- if (mutations.length === 0) return;
448
+ if (mutations.length === 0) {
449
+ const clients = await sw.clients.matchAll();
450
+ clients.forEach((client) => { client.postMessage({ type: 'MUTATIONS_BLOCKED', blocked: [] }); });
451
+ return;
452
+ }
257
453
 
258
454
  let replayCsrfToken: string | null;
455
+ let currentUserId: string | null = null;
259
456
  try {
260
457
  const bootstrap = await fetch('/api/auth/me', {
261
458
  method: 'GET',
@@ -267,19 +464,50 @@ async function flushMutationQueue(): Promise<void> {
267
464
  console.warn('Cannot replay offline mutations without a CSRF bootstrap token');
268
465
  return;
269
466
  }
467
+ // The bootstrap response is also the authoritative identity check: the
468
+ // signed-in account decides which records may replay. A 401 (or any
469
+ // unparseable body) means nobody is signed in, so every record is kept.
470
+ const me = await bootstrap.json().catch(() => null) as MeBody | null;
471
+ const bootstrapUserId = me?.user?.id;
472
+ currentUserId = typeof bootstrapUserId === 'string' ? bootstrapUserId : null;
270
473
  } catch {
271
474
  // The network failed during token bootstrap; preserve the entire queue.
272
475
  return;
273
476
  }
274
477
 
478
+ const blocked: BlockedMutation[] = [];
479
+ const blockedWorkspaces = new Set<string | null>();
275
480
  let replayed = 0;
276
481
  for (const mut of mutations) {
482
+ // Derive legacy scope from the path rather than assuming it is independent.
483
+ const workspace = workspaceFromPath(mut.path);
484
+ const reason = replayBlockReason(mut, currentUserId)
485
+ ?? (mut.state === 'needs-attention' ? mut.attentionReason ?? 'outcome-unknown' : null)
486
+ ?? (blockedWorkspaces.has(null) || (workspace === null ? blockedWorkspaces.size > 0 : blockedWorkspaces.has(workspace))
487
+ ? 'workspace-order' : null);
488
+ if (reason) {
489
+ // Never replayed, never deleted: kept for its owner and surfaced so the
490
+ // page can offer explicit recovery. Skipping does not stop the loop, so
491
+ // blocked records cannot wedge unrelated owned records behind them.
492
+ blocked.push({
493
+ id: mut.id,
494
+ method: mut.method,
495
+ path: mut.path,
496
+ ownerId: mut.ownerId ?? null,
497
+ workspace,
498
+ reason,
499
+ });
500
+ if (mut.ownerId === currentUserId && currentUserId !== null) blockedWorkspaces.add(workspace);
501
+ continue;
502
+ }
277
503
  try {
278
504
  const opts: RequestInit = {
279
505
  method: mut.method,
280
506
  headers: {
281
507
  'Content-Type': 'application/json',
282
508
  'X-CSRF-Token': replayCsrfToken,
509
+ 'X-PM-Expected-Account': mut.ownerId!,
510
+ ...(mut.idempotencyKey ? { 'Idempotency-Key': mut.idempotencyKey } : {}),
283
511
  },
284
512
  credentials: 'include',
285
513
  };
@@ -295,8 +523,27 @@ async function flushMutationQueue(): Promise<void> {
295
523
  replayed++;
296
524
  } else {
297
525
  console.warn('Offline mutation failed:', mut.method, mut.path, res.status);
298
- // Stop processing on first failure — try again later
299
- break;
526
+ const body = await res.json().catch(() => null) as { code?: unknown } | null;
527
+ let attention: QueuedMutation['attentionReason'];
528
+ if (body?.code === 'PM_IDEMPOTENCY_OUTCOME_UNKNOWN') attention = 'outcome-unknown';
529
+ if (res.status >= 500) {
530
+ mut.serverFailures = (mut.serverFailures ?? 0) + 1;
531
+ if (mut.serverFailures >= 3) attention = 'repeated-5xx';
532
+ } else if (!attention && mut.serverFailures) {
533
+ mut.serverFailures = 0;
534
+ }
535
+ if (attention) {
536
+ mut.state = 'needs-attention';
537
+ mut.attentionReason = attention;
538
+ blocked.push({ id: mut.id, method: mut.method, path: mut.path,
539
+ ownerId: mut.ownerId ?? null, workspace, reason: attention });
540
+ }
541
+ // Persist before allowing independent workspace replay; failure stops the flush.
542
+ const db = await openMutationDB();
543
+ const tx = db.transaction(MUTATION_STORE, 'readwrite');
544
+ tx.objectStore(MUTATION_STORE).put(mut);
545
+ await transactionDone(tx);
546
+ blockedWorkspaces.add(workspace);
300
547
  }
301
548
  } catch {
302
549
  // Network failed again — stop processing
@@ -307,6 +554,10 @@ async function flushMutationQueue(): Promise<void> {
307
554
  // Notify clients about replayed mutations
308
555
  const remainingRead = await getQueuedMutations();
309
556
  const clients = await sw.clients.matchAll();
557
+ // Empty reports remove a previously rendered or dismissed recovery set.
558
+ clients.forEach((client) => {
559
+ client.postMessage({ type: 'MUTATIONS_BLOCKED', blocked });
560
+ });
310
561
  if (!remainingRead.ok) {
311
562
  // Could not re-read the queue — do NOT claim all mutations were replayed.
312
563
  // Report a partial result with the known replayed count so unreplayed
@@ -332,13 +583,90 @@ async function flushMutationQueue(): Promise<void> {
332
583
  clients.forEach((client) => {
333
584
  client.postMessage({
334
585
  type: 'MUTATIONS_PARTIAL',
335
- replayed: mutations.length - remaining.length,
586
+ replayed,
336
587
  remaining: remaining.length,
337
588
  });
338
589
  });
339
590
  }
340
591
  }
341
592
 
593
+ /** Pending queue operations share one chain across messages, sync and online events. */
594
+ let queueOperation: Promise<void> = Promise.resolve();
595
+
596
+ /** Serialize queue adoption and replay, recovering the chain after an operation fails. */
597
+ function runQueueOperation(operation: () => Promise<void>): Promise<void> {
598
+ const pending = queueOperation.then(async () => {
599
+ await sessionUpdate.catch(() => {});
600
+ await operation();
601
+ });
602
+ queueOperation = pending.catch(() => {});
603
+ return pending;
604
+ }
605
+
606
+ /** Flush under the same single-flight chain used for explicit recovery. */
607
+ function flushMutationQueue(): Promise<void> {
608
+ return runQueueOperation(replayQueuedMutations);
609
+ }
610
+
611
+ /**
612
+ * Explicitly rebind unknown-owner records to the signed-in account.
613
+ *
614
+ * The page offers this after a flush surfaces records with the `unknown-owner`
615
+ * reason: the signed-in user states that this queued work is theirs. Only
616
+ * records whose ownership is unknown are adoptable — a record already owned by
617
+ * another account is never reassigned, because its true owner may still
618
+ * return to replay it.
619
+ *
620
+ * @param ids - Only these record ids are adopted, or all unknown records when absent.
621
+ * @param ownerId - The approving page's account, verified against the server session.
622
+ * @returns Whether the current server session approved adoption.
623
+ */
624
+ function adoptUnknownOwnerRecords(ids: number[] | undefined, ownerId: string): Promise<boolean> {
625
+ return updateRecoveryRecords(ownerId, (store, mut) => {
626
+ if ((mut.ownerId === undefined || mut.ownerId === null)
627
+ && (ids === undefined || ids.includes(mut.id))
628
+ && !/^\/auth(?:\/|\?|$)/i.test(mut.path)) {
629
+ store.put({ ...mut, ownerId, idempotencyKey: mut.idempotencyKey || newIdempotencyKey() });
630
+ }
631
+ });
632
+ }
633
+
634
+ /** Adopt records through the common queue-operation chain used by flushes. */
635
+ function rebindUnknownOwnerRecords(ids: number[] | undefined, ownerId: string): Promise<void> {
636
+ return runQueueOperation(async () => { await adoptUnknownOwnerRecords(ids, ownerId); });
637
+ }
638
+
639
+ /** Apply owner-approved retry with a fresh key or discard atomically; never change another owner's work. */
640
+ function recoverAttentionRecord(id: number, ownerId: string, action: 'retry-new' | 'discard'): Promise<boolean> {
641
+ return updateRecoveryRecords(ownerId, (store, mut) => {
642
+ if (mut.id !== id || mut.ownerId !== ownerId || mut.state !== 'needs-attention') return;
643
+ if (action === 'discard') store.delete(id);
644
+ else store.put({ ...mut, state: 'queued', attentionReason: undefined, serverFailures: 0, idempotencyKey: newIdempotencyKey() });
645
+ });
646
+ }
647
+
648
+ /** Verify the approving account, then read and update recovery records in one live IndexedDB transaction. */
649
+ async function updateRecoveryRecords(ownerId: string, update: (store: IDBObjectStore, mut: QueuedMutation) => void): Promise<boolean> {
650
+ const approval = await fetch('/api/auth/me', {
651
+ credentials: 'include', cache: 'no-store', headers: { 'X-PM-Expected-Account': ownerId },
652
+ });
653
+ if (!approval.ok) return false;
654
+ const me = await approval.json().catch(() => null) as MeBody | null;
655
+ if (me?.user?.id !== ownerId) return false;
656
+ const db = await openMutationDB();
657
+ const tx = db.transaction(MUTATION_STORE, 'readwrite');
658
+ const done = transactionDone(tx);
659
+ const store = tx.objectStore(MUTATION_STORE);
660
+ const request = store.getAll();
661
+ request.onsuccess = () => {
662
+ for (const mut of request.result as QueuedMutation[]) {
663
+ update(store, mut);
664
+ }
665
+ };
666
+ await done;
667
+ return true;
668
+ }
669
+
342
670
  // ── Install ──
343
671
  sw.addEventListener('install', (event: ExtendableEvent) => {
344
672
  event.waitUntil(
@@ -365,32 +693,53 @@ sw.addEventListener('fetch', (event: FetchEvent) => {
365
693
 
366
694
  // API calls: try network, queue mutations if offline
367
695
  if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/healthz')) {
696
+ // Session responses must reach the browser with their live cookie changes.
697
+ // Remove caller keys too; failed auth requests are never queued.
698
+ if (/^\/api\/auth(?:\/|$)/i.test(url.pathname)) {
699
+ const headers = new Headers(event.request.headers);
700
+ headers.delete('Idempotency-Key');
701
+ headers.delete('X-PM-Expected-Account');
702
+ event.respondWith(fetch(new Request(event.request, { headers })));
703
+ return;
704
+ }
368
705
  // Queue write operations (POST, PUT, PATCH, DELETE) when offline
369
706
  if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
707
+ const headers = new Headers(event.request.headers);
708
+ if (!headers.has('Idempotency-Key')) headers.set('Idempotency-Key', newIdempotencyKey());
370
709
  event.respondWith(
371
- fetch(event.request).catch(async () => {
372
- // Network failed — queue the mutation for later.
373
- let body: unknown = undefined;
374
- try {
375
- body = await event.request.clone().json();
376
- } catch { /* no body */ }
377
- const queued = await queueMutation(
378
- event.request.method,
379
- url.pathname.replace('/api', ''),
380
- body,
381
- );
382
- if (queued) {
710
+ (async () => {
711
+ await sessionUpdate.catch(() => {});
712
+ // Capture identity before sending, never after a lost response.
713
+ const ownerId = headers.get('X-PM-Expected-Account') ?? await readSession();
714
+ if (ownerId !== null) headers.set('X-PM-Expected-Account', ownerId);
715
+ const request = new Request(event.request, { headers });
716
+ const queuedRequest = request.clone();
717
+ return fetch(request).catch(async () => {
718
+ // Network failed — queue the mutation for later.
719
+ let body: unknown = undefined;
720
+ try {
721
+ body = await queuedRequest.json();
722
+ } catch { /* no body */ }
723
+ const queued = await queueMutation(
724
+ event.request.method,
725
+ url.pathname.replace('/api', '') + url.search,
726
+ body,
727
+ headers.get('Idempotency-Key') ?? newIdempotencyKey(),
728
+ ownerId,
729
+ );
730
+ if (queued) {
731
+ return new Response(
732
+ JSON.stringify({ queued: true, message: 'Request queued for when you are back online' }),
733
+ { status: 202, headers: { 'Content-Type': 'application/json' } },
734
+ );
735
+ }
736
+ // Persistence failed — do not claim the mutation was queued.
383
737
  return new Response(
384
- JSON.stringify({ queued: true, message: 'Request queued for when you are back online' }),
385
- { status: 202, headers: { 'Content-Type': 'application/json' } },
738
+ JSON.stringify({ error: 'Offline and unable to queue mutation', queued: false }),
739
+ { status: 503, headers: { 'Content-Type': 'application/json' } },
386
740
  );
387
- }
388
- // Persistence failed — do not claim the mutation was queued.
389
- return new Response(
390
- JSON.stringify({ error: 'Offline and unable to queue mutation', queued: false }),
391
- { status: 503, headers: { 'Content-Type': 'application/json' } },
392
- );
393
- }),
741
+ });
742
+ })(),
394
743
  );
395
744
  return;
396
745
  }
@@ -489,9 +838,14 @@ sw.addEventListener('fetch', (event: FetchEvent) => {
489
838
  );
490
839
  });
491
840
 
841
+ // Serialise session writes and recovery so adoption cannot read an older login.
842
+ let sessionUpdate: Promise<void> = Promise.resolve();
843
+
492
844
  // ── Messages ──
493
845
  sw.addEventListener('message', (event: ExtendableMessageEvent) => {
494
- const data = event.data as { type?: string; urls?: string[] } | null;
846
+ const data = event.data as
847
+ | { type?: string; urls?: string[]; userId?: unknown; ids?: unknown; ownerId?: unknown; id?: unknown; action?: unknown }
848
+ | null;
495
849
  if (data && data.type === 'SKIP_WAITING') {
496
850
  sw.skipWaiting();
497
851
  }
@@ -500,7 +854,44 @@ sw.addEventListener('message', (event: ExtendableMessageEvent) => {
500
854
  caches.open(CACHE_NAME).then((cache) => cache.addAll(urls).catch(() => {}));
501
855
  }
502
856
  if (data && data.type === 'FLUSH_QUEUE') {
503
- void flushMutationQueue();
857
+ event.waitUntil(sessionUpdate.then(() => flushMutationQueue()).catch(() => {
858
+ console.warn('Failed to flush offline mutations');
859
+ }));
860
+ }
861
+ if (data && data.type === 'AUTH_SESSION') {
862
+ // The page names the signed-in account so queued mutations are bound to
863
+ // it. `null` (logout) is a valid broadcast: it means ownership of later
864
+ // queues is unknown, never the next signed-in account.
865
+ const userId = typeof data.userId === 'string' ? data.userId : null;
866
+ sessionUpdate = sessionUpdate.catch(() => {}).then(() => saveSession(userId));
867
+ event.waitUntil(sessionUpdate.catch(() => {
868
+ console.warn('Failed to persist offline session');
869
+ }));
870
+ }
871
+ if (data && data.type === 'REBIND_RECORDS') {
872
+ // Explicit recovery: adopt unknown-owner records for the signed-in
873
+ // account. The ids are optional; without them every unknown-owner record
874
+ // is adopted.
875
+ const ids = Array.isArray(data.ids)
876
+ ? data.ids.filter((id): id is number => typeof id === 'number')
877
+ : undefined;
878
+ const ownerId = data.ownerId;
879
+ if (typeof ownerId !== 'string' || ownerId.length === 0) return;
880
+ event.waitUntil(runQueueOperation(async () => {
881
+ if (await adoptUnknownOwnerRecords(ids, ownerId)) await replayQueuedMutations();
882
+ }).catch(() => {
883
+ console.warn('Failed to adopt offline mutations');
884
+ }));
885
+ }
886
+ if (data && data.type === 'RECOVER_RECORD') {
887
+ const { id, ownerId, action } = data;
888
+ if (typeof id !== 'number' || !Number.isSafeInteger(id) || typeof ownerId !== 'string' || ownerId.length === 0
889
+ || (action !== 'retry-new' && action !== 'discard')) return;
890
+ event.waitUntil(runQueueOperation(async () => {
891
+ if (await recoverAttentionRecord(id, ownerId, action)) await replayQueuedMutations();
892
+ }).catch(() => {
893
+ console.warn('Failed to recover offline mutation');
894
+ }));
504
895
  }
505
896
  });
506
897
 
@@ -533,5 +924,12 @@ sw.addEventListener('online', () => {
533
924
  // rather than keeping both.
534
925
  const __testGlobals = globalThis as unknown as Record<string, unknown>;
535
926
  if (__testGlobals.__swTestHarness) {
536
- __testGlobals.__swInternals = { getQueuedMutations, flushMutationQueue, queueMutation, clearMutation };
927
+ __testGlobals.__swInternals = {
928
+ getQueuedMutations,
929
+ flushMutationQueue,
930
+ queueMutation,
931
+ clearMutation,
932
+ saveSession,
933
+ rebindRecords: rebindUnknownOwnerRecords,
934
+ };
537
935
  }
@@ -2,7 +2,7 @@
2
2
  // AUTH VIEW
3
3
  // ═══════════════════════════════════════════════════════════════
4
4
  import { state } from '../state.js';
5
- import { api } from '../api.js';
5
+ import { api, syncServiceWorkerSession } from '../api.js';
6
6
  import { bootApp } from '../app.js';
7
7
  import { t, translateError } from '../i18n.js';
8
8
  /** Store the signed-in user outside the async submit handler. */
@@ -99,6 +99,7 @@ export async function submitAuth(e) {
99
99
  data = await api('POST', '/auth/register', { email, password, displayName: name || email.split('@')[0] });
100
100
  }
101
101
  rememberSignedInUser(data.user);
102
+ syncServiceWorkerSession(data.user);
102
103
  await bootApp();
103
104
  }
104
105
  catch (err) {
@@ -121,6 +122,7 @@ export async function logout() {
121
122
  state.user = null;
122
123
  state.projects = [];
123
124
  state.currentProject = null;
125
+ syncServiceWorkerSession(null);
124
126
  showAuth();
125
127
  }
126
128
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,YAAY;AACZ,kEAAkE;AAClE,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACpC,OAAO,EAAE,GAAG,EAAE,wBAAwB,EAAE,MAAM,WAAW,CAAC;AAC1D,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,CAAC,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAG/C,iEAAiE;AACjE,SAAS,oBAAoB,CAAC,IAAU;IACtC,KAAK,CAAC,IAAI,GAAG,IAAI,CAAC;AACpB,CAAC;AAED;;;;GAIG;AACH,KAAK,UAAU,kBAAkB;IAC/B,MAAM,MAAM,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAA6B,CAAC;IACjF,MAAM,OAAO,GAAG,QAAQ,CAAC,cAAc,CAAC,cAAc,CAAuB,CAAC;IAC9E,IAAI,CAAC,MAAM;QAAE,OAAO;IACpB,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,KAAK,EAAE,mBAAmB,CAAwC,CAAC;QAC5F,MAAM,CAAC,MAAM,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC;QAChC,IAAI,OAAO;YAAE,OAAO,CAAC,MAAM,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC;QAC9C,MAAM,CAAC,WAAW,GAAG,CAAC,CAAC,oBAAoB,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;IACxE,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,CAAC,MAAM,GAAG,IAAI,CAAC;QACrB,IAAI,OAAO;YAAE,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IACrC,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc;IAC5B,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,sBAAsB,CAAC,CAAC;AACjD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,GAAyB;IACrD,KAAK,CAAC,OAAO,GAAG,GAAG,CAAC;IACpB,QAAQ,CAAC,cAAc,CAAC,WAAW,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,QAAQ,EAAE,GAAG,KAAG,OAAO,CAAC,CAAC;IAChF,QAAQ,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,SAAS,CAAC,MAAM,CAAC,QAAQ,EAAE,GAAG,KAAG,UAAU,CAAC,CAAC;IACtF,MAAM,SAAS,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAAuB,CAAC;IAC9E,IAAI,SAAS;QAAE,SAAS,CAAC,KAAK,CAAC,OAAO,GAAG,GAAG,KAAG,UAAU,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC;IACxE,MAAM,SAAS,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC;IACxD,IAAI,SAAS;QAAE,SAAS,CAAC,WAAW,GAAG,GAAG,KAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC;IACxG,MAAM,OAAO,GAAG,QAAQ,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC;IACpD,IAAI,OAAO;QAAE,OAAO,CAAC,WAAW,GAAG,GAAG,KAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC;IAChG,MAAM,WAAW,GAAG,QAAQ,CAAC,cAAc,CAAC,eAAe,CAAC,CAAC;IAC7D,IAAI,WAAW;QAAE,WAAW,CAAC,WAAW,GAAG,GAAG,KAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,sBAAsB,CAAC,CAAC;IAC9G,MAAM,SAAS,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAAuB,CAAC;IAC9E,IAAI,SAAS;QAAE,SAAS,CAAC,KAAK,CAAC,OAAO,GAAG,MAAM,CAAC;AAClD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,CAAQ;IACvC,CAAC,CAAC,cAAc,EAAE,CAAC;IACnB,MAAM,GAAG,GAAG,QAAQ,CAAC,cAAc,CAAC,aAAa,CAA6B,CAAC;IAC/E,MAAM,KAAK,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAAuB,CAAC;IAC1E,MAAM,OAAO,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAA4B,CAAC;IACjF,MAAM,UAAU,GAAG,QAAQ,CAAC,cAAc,CAAC,eAAe,CAA4B,CAAC;IACvF,MAAM,MAAM,GAAG,QAAQ,CAAC,cAAc,CAAC,WAAW,CAA4B,CAAC;IAE/E,IAAI,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,CAAC,OAAO,IAAI,CAAC,UAAU;QAAE,OAAO;IAEtD,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;IACnC,MAAM,QAAQ,GAAG,UAAU,CAAC,KAAK,CAAC;IAClC,MAAM,IAAI,GAAG,MAAM,EAAE,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC;IAExC,KAAK,CAAC,KAAK,CAAC,OAAO,GAAG,MAAM,CAAC;IAC7B,GAAG,CAAC,QAAQ,GAAG,IAAI,CAAC;IACpB,MAAM,IAAI,GAAG,GAAG,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC,IAAI,IAAI;QAAE,IAAI,CAAC,WAAW,GAAG,CAAC,CAAC,cAAc,CAAC,CAAC;IAE/C,IAAI,CAAC;QACH,IAAI,IAAoB,CAAC;QACzB,IAAI,KAAK,CAAC,OAAO,KAAK,OAAO,EAAE,CAAC;YAC9B,IAAI,GAAG,MAAM,GAAG,CAAC,MAAM,EAAC,aAAa,EAAC,EAAC,KAAK,EAAC,QAAQ,EAAC,CAAC,CAAC;QAC1D,CAAC;aAAM,CAAC;YACN,IAAI,GAAG,MAAM,GAAG,CAAC,MAAM,EAAC,gBAAgB,EAAC,EAAC,KAAK,EAAC,QAAQ,EAAC,WAAW,EAAC,IAAI,IAAE,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAC,CAAC,CAAC;QACnG,CAAC;QACD,oBAAoB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAChC,wBAAwB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACpC,MAAM,OAAO,EAAE,CAAC;IAClB,CAAC;IAAC,OAAM,GAAY,EAAE,CAAC;QACrB,KAAK,CAAC,WAAW,GAAG,cAAc,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;QACrF,KAAK,CAAC,KAAK,CAAC,OAAO,GAAG,OAAO,CAAC;QAC9B,GAAG,CAAC,QAAQ,GAAG,KAAK,CAAC;QACrB,IAAI,IAAI;YAAE,IAAI,CAAC,WAAW,GAAG,KAAK,CAAC,OAAO,KAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,sBAAsB,CAAC,CAAC;IAC5G,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,MAAM;IAC1B,IAAI,CAAC;QAAC,MAAM,GAAG,CAAC,MAAM,EAAC,cAAc,EAAC,EAAE,CAAC,CAAC;IAAC,CAAC;IAAC,OAAM,CAAC,EAAE,CAAC,CAAC,YAAY,CAAC,CAAC;IACtE,KAAK,CAAC,IAAI,GAAG,IAAI,CAAC;IAClB,KAAK,CAAC,QAAQ,GAAG,EAAE,CAAC;IACpB,KAAK,CAAC,cAAc,GAAG,IAAI,CAAC;IAC5B,wBAAwB,CAAC,IAAI,CAAC,CAAC;IAC/B,QAAQ,EAAE,CAAC;AACb,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,QAAQ;IACtB,MAAM,UAAU,GAAG,QAAQ,CAAC,cAAc,CAAC,aAAa,CAAC,CAAC;IAC1D,MAAM,OAAO,GAAG,QAAQ,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC;IACpD,IAAI,UAAU;QAAE,UAAU,CAAC,KAAK,CAAC,OAAO,GAAG,MAAM,CAAC;IAClD,IAAI,OAAO;QAAE,OAAO,CAAC,KAAK,CAAC,OAAO,GAAG,MAAM,CAAC;IAC5C,KAAK,kBAAkB,EAAE,CAAC;AAC5B,CAAC"}