@ours.network/notifications 0.3.1-nightly.2 → 0.3.1-nightly.3

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/README.md CHANGED
@@ -140,3 +140,34 @@ limit remove frozen/disconnected clients. Only that foreground device's push
140
140
  is suppressed; its inbox item remains unread until explicitly marked read.
141
141
  Other devices still receive push. `inbox.changed` invalidates account-wide inbox
142
142
  views after producer admission or reads. Presence is ephemeral across restart.
143
+
144
+ ## Task lifecycle delivery and diagnostics
145
+
146
+ Task notifications from Fleet identify creation and every committed lifecycle change,
147
+ including block/unblock and deletion. Task routes remain eligible for push while a
148
+ registered device is foreground; ordinary chat delivery keeps its existing server
149
+ presence policy. The browser must display every push it has already received:
150
+ Safari can revoke subscriptions after silent pushes, so suppression must happen
151
+ before sending, never by returning from the worker's push handler.
152
+
153
+ `GET /api/v1/delivery/status` requires the owner's user credential (never a producer
154
+ credential). It returns active device/pending-job counts and the latest 100 safe
155
+ outcomes for that account: provider acceptance, foreground suppression, expired
156
+ endpoint, scheduled retry, cancellation or no registered device. The service retains
157
+ at most 1,000 outcomes per account. Records contain IDs, timestamps, attempt counts
158
+ and numeric provider status only; endpoints, encryption keys, notification contents
159
+ and provider bodies are excluded. `provider_accepted` proves provider acceptance,
160
+ not display or receipt on a phone.
161
+
162
+ Failed push jobs persist with exponential backoff capped at one hour until they
163
+ succeed, are read/deleted, or their endpoint expires (404/410). Restart preserves
164
+ retry work. Re-registering a subscription with changed keys gets a new device ID,
165
+ so a late expiry response cannot remove its replacement. Background subscription
166
+ renewal requires the workspace page to reopen and reconcile its subscription;
167
+ revoked permission requires an explicit user action in browser/device settings.
168
+
169
+ Acceptance receipts retain event identity and a canonical payload hash even after
170
+ an inbox item is deleted, preventing retry resurrection. Legacy inbox entries are
171
+ admitted as receipts on load. The existing inbox/job caps remain; a separate cap of
172
+ 100,000 receipts per account rejects new events explicitly rather than evicting
173
+ dedupe evidence. Upgrades must preserve the service's state file and scoped credentials.
package/dist/server.js CHANGED
@@ -61,6 +61,8 @@ export function createNotificationService(config, send) {
61
61
  let result;
62
62
  if (req.method === 'GET' && url.pathname === '/api/v1/push/config')
63
63
  result = { publicKey: config.vapid.publicKey };
64
+ else if (req.method === 'GET' && url.pathname === '/api/v1/delivery/status')
65
+ result = store.deliveryStatus(user);
64
66
  else if (req.method === 'GET' && url.pathname === '/api/v1/summary')
65
67
  result = store.summary(user);
66
68
  else if (req.method === 'GET' && url.pathname === '/api/v1/inbox') {
@@ -128,21 +130,21 @@ export function createNotificationService(config, send) {
128
130
  const drain = () => draining ??= (async () => {
129
131
  for (const j of store.due(Date.now())) {
130
132
  if (!j.device || !j.notification || j.notification.readAt) {
131
- store.finish(j.id, 'sent', Date.now());
133
+ store.finish(j.id, 'sent', Date.now(), j.notification?.readAt ? 'read_cancelled' : 'missing_target');
132
134
  continue;
133
135
  }
134
- if (presence.online(j.device.userId, j.device.id)) {
135
- store.finish(j.id, 'sent', Date.now());
136
+ if (!/^\/fleet\/tasks(?:\/[^/]+)?\/?$/.test(new URL(j.notification.url, 'https://ours.invalid').pathname) && presence.online(j.device.userId, j.device.id)) {
137
+ store.finish(j.id, 'sent', Date.now(), 'foreground_suppressed');
136
138
  continue;
137
139
  }
138
140
  try {
139
- await deliver(j.device.subscription, JSON.stringify({ v: 1, kind: 'notification', title: j.notification.title,
141
+ const result = await deliver(j.device.subscription, JSON.stringify({ v: 1, kind: 'notification', title: j.notification.title,
140
142
  body: j.notification.body, url: j.notification.url, notification_id: j.notification.id, tag: j.notification.id }));
141
- store.finish(j.id, 'sent', Date.now());
143
+ store.finish(j.id, 'sent', Date.now(), 'provider_accepted', result?.statusCode);
142
144
  }
143
145
  catch (error) {
144
146
  const status = error.statusCode;
145
- store.finish(j.id, status === 404 || status === 410 ? 'gone' : 'retry', Date.now());
147
+ store.finish(j.id, status === 404 || status === 410 ? 'gone' : 'retry', Date.now(), status === 404 || status === 410 ? 'endpoint_expired' : 'retry_scheduled', status);
146
148
  }
147
149
  }
148
150
  })().finally(() => { draining = undefined; });
package/dist/store.d.ts CHANGED
@@ -17,6 +17,16 @@ interface Device {
17
17
  userId: string;
18
18
  subscription: PushSubscription;
19
19
  }
20
+ export type DeliveryOutcome = 'provider_accepted' | 'foreground_suppressed' | 'endpoint_expired' | 'retry_scheduled' | 'read_cancelled' | 'missing_target' | 'no_device';
21
+ interface Delivery {
22
+ userId: string;
23
+ notificationId: string;
24
+ deviceId?: string;
25
+ at: string;
26
+ outcome: DeliveryOutcome;
27
+ attempt: number;
28
+ statusCode?: number;
29
+ }
20
30
  /** Single-writer local service. Mutations commit a fsynced atomic snapshot before acknowledgement. */
21
31
  export declare class Store {
22
32
  private readonly file;
@@ -57,7 +67,10 @@ export declare class Store {
57
67
  deleteNotifications(userId: string, ids: string[]): number;
58
68
  /** A producer may retire only its own notifications for the canonical conversation. */
59
69
  deleteTarget(userId: string, source: string, url: string): number;
60
- produce(value: Omit<Notification, 'id' | 'createdAt' | 'readAt'>): Notification;
70
+ produce(value: Omit<Notification, 'id' | 'createdAt' | 'readAt'>): Notification | {
71
+ id: string;
72
+ duplicate: true;
73
+ };
61
74
  due(now: number): {
62
75
  device: Device | undefined;
63
76
  notification: Notification | undefined;
@@ -67,6 +80,13 @@ export declare class Store {
67
80
  attempts: number;
68
81
  due: number;
69
82
  }[];
70
- finish(id: string, status: 'sent' | 'gone' | 'retry', now: number): void;
83
+ /** Bounded, account-scoped operational evidence; never endpoints, keys, payloads or provider bodies. */
84
+ deliveryStatus(userId: string): {
85
+ devices: number;
86
+ pending: number;
87
+ recent: Delivery[];
88
+ };
89
+ private recordDelivery;
90
+ finish(id: string, status: 'sent' | 'gone' | 'retry', now: number, outcome?: DeliveryOutcome, statusCode?: number): void;
71
91
  }
72
92
  export {};
package/dist/store.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { chatTarget, chatKey } from './chat-target.js';
2
- import { randomUUID } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
4
4
  import { dirname } from 'node:path';
5
5
  import { HttpError } from './validation.js';
6
+ const fingerprint = (value) => createHash('sha256')
7
+ .update(JSON.stringify([value.userId, value.source, value.eventId, value.title, value.body, value.url, value.taskId === undefined ? [] : [value.taskId]])).digest('hex');
6
8
  /** Single-writer local service. Mutations commit a fsynced atomic snapshot before acknowledgement. */
7
9
  export class Store {
8
10
  file;
@@ -29,6 +31,8 @@ export class Store {
29
31
  }
30
32
  try {
31
33
  this.state = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { version: 1, revision: 0, devices: [], notifications: [], jobs: [] };
34
+ this.state.receipts ??= this.state.notifications.map(n => ({ userId: n.userId, source: n.source, eventId: n.eventId, id: n.id, fingerprint: fingerprint(n) }));
35
+ this.state.deliveries ??= [];
32
36
  if (this.state.version !== 1 || !Array.isArray(this.state.notifications) || !Array.isArray(this.state.devices) || !Array.isArray(this.state.jobs))
33
37
  throw new Error('invalid notification state');
34
38
  }
@@ -107,7 +111,7 @@ export class Store {
107
111
  const sections = { chats: 0, tasks: 0 };
108
112
  for (const n of unread) {
109
113
  const url = new URL(n.url, 'https://ours.invalid'), targetForSection = chatTarget(n.url);
110
- if (/^\/fleet\/tasks\/[^/]+\/?$/.test(url.pathname))
114
+ if (/^\/fleet\/tasks(?:\/[^/]+)?\/?$/.test(url.pathname))
111
115
  sections.tasks++;
112
116
  else if (targetForSection) {
113
117
  if (typeof n.taskId === 'string' || targetForSection.source === 'work' && /^room-[^:]+(?::[^:]+)?$/.test(targetForSection.chat))
@@ -176,18 +180,26 @@ export class Store {
176
180
  return this.deleteNotifications(userId, ids);
177
181
  }
178
182
  produce(value) {
183
+ const receipt = this.state.receipts.find(r => r.userId === value.userId && r.source === value.source && r.eventId === value.eventId);
184
+ if (receipt && receipt.fingerprint !== fingerprint(value))
185
+ throw new HttpError(409, 'event id reused');
179
186
  const previous = this.state.notifications.find(n => n.userId === value.userId && n.source === value.source && n.eventId === value.eventId);
180
187
  if (previous) {
181
188
  if (previous.title !== value.title || previous.body !== value.body || previous.url !== value.url || previous.taskId !== value.taskId)
182
189
  throw new HttpError(409, 'event id reused');
183
190
  return previous;
184
191
  }
192
+ if (receipt)
193
+ return { id: receipt.id, duplicate: true };
185
194
  // Explicit capacity failure instead of silently evicting unread history or dedupe records.
186
- if (this.state.notifications.filter(n => n.userId === value.userId).length >= 10_000 || this.state.jobs.length >= 20_000)
195
+ if (this.state.receipts.filter(r => r.userId === value.userId).length >= 100_000 || this.state.notifications.filter(n => n.userId === value.userId).length >= 10_000 || this.state.jobs.length >= 20_000)
187
196
  throw new HttpError(503, 'notification capacity exceeded');
188
197
  const n = { ...value, id: randomUUID(), createdAt: new Date().toISOString(), createdRevision: this.state.revision + 1, readAt: null };
189
198
  this.commit(s => {
190
199
  s.notifications.push(n);
200
+ s.receipts.push({ userId: n.userId, source: n.source, eventId: n.eventId, id: n.id, fingerprint: fingerprint(n) });
201
+ if (!s.devices.some(d => d.userId === n.userId))
202
+ this.recordDelivery(s, { userId: n.userId, notificationId: n.id, at: n.createdAt, outcome: 'no_device', attempt: 0 });
191
203
  for (const d of s.devices)
192
204
  if (d.userId === n.userId)
193
205
  s.jobs.push({ id: randomUUID(), notificationId: n.id, deviceId: d.id, attempts: 0, due: Date.now() });
@@ -198,20 +210,35 @@ export class Store {
198
210
  return this.state.jobs.filter(j => j.due <= now).slice(0, 32).map(j => ({ ...j,
199
211
  device: this.state.devices.find(d => d.id === j.deviceId), notification: this.state.notifications.find(n => n.id === j.notificationId) }));
200
212
  }
201
- finish(id, status, now) {
213
+ /** Bounded, account-scoped operational evidence; never endpoints, keys, payloads or provider bodies. */
214
+ deliveryStatus(userId) {
215
+ return { devices: this.state.devices.filter(d => d.userId === userId).length,
216
+ pending: this.state.jobs.filter(j => this.state.notifications.some(n => n.id === j.notificationId && n.userId === userId)).length,
217
+ recent: this.state.deliveries.filter(d => d.userId === userId).slice(-100).reverse() };
218
+ }
219
+ recordDelivery(state, value) {
220
+ const sameUser = state.deliveries.filter(d => d.userId === value.userId).slice(-999);
221
+ state.deliveries = [...state.deliveries.filter(d => d.userId !== value.userId), ...sameUser, value];
222
+ }
223
+ finish(id, status, now, outcome = status === 'sent' ? 'provider_accepted' : status === 'gone' ? 'endpoint_expired' : 'retry_scheduled', statusCode) {
202
224
  this.commit(s => {
203
225
  const j = s.jobs.find(j => j.id === id);
204
226
  if (!j)
205
227
  return;
228
+ const n = s.notifications.find(n => n.id === j.notificationId);
229
+ if (n)
230
+ this.recordDelivery(s, { userId: n.userId, notificationId: n.id, deviceId: j.deviceId,
231
+ at: new Date(now).toISOString(), outcome, attempt: j.attempts + 1,
232
+ ...(Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599 ? { statusCode } : {}) });
206
233
  if (status === 'gone') {
207
234
  s.devices = s.devices.filter(d => d.id !== j.deviceId);
208
235
  s.jobs = s.jobs.filter(v => v.deviceId !== j.deviceId);
209
236
  }
210
- else if (status === 'sent' || j.attempts >= 7)
237
+ else if (status === 'sent')
211
238
  s.jobs = s.jobs.filter(v => v.id !== id);
212
239
  else {
213
- j.attempts++;
214
- j.due = now + Math.min(60_000 * 2 ** j.attempts, 3_600_000);
240
+ j.attempts = Math.min(j.attempts + 1, 1024);
241
+ j.due = now + Math.min(60_000 * 2 ** Math.min(j.attempts, 6), 3_600_000);
215
242
  }
216
243
  });
217
244
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/notifications",
3
- "version": "0.3.1-nightly.2",
3
+ "version": "0.3.1-nightly.3",
4
4
  "description": "Authenticated web push delivery and shared notification inbox",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",