@ours.network/notifications 0.3.1-nightly.4 → 0.3.1-nightly.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/README.md CHANGED
@@ -173,3 +173,7 @@ admitted as receipts on load. The existing inbox/job caps remain; a separate cap
173
173
  dedupe evidence. Upgrades must preserve the service's state file and scoped credentials.
174
174
 
175
175
  Conversation notification links use canonical contact (`/fleet/chats/contacts/:id`), agent (`/fleet/chats/agents/:id`), and task room (`/fleet/tasks/:taskId/room` or `/fleet/tasks/:taskId/rooms/:key`) paths. Deploy producers, this service, and the app together. Summary rows retain `source`/`chat` and include `kind` (`contact`, `agent`, or `room`) to distinguish colliding IDs. Filters and message anchors do not change target identity. Old query-based conversation URLs are intentionally unsupported; stored payload URLs are neither converted nor deleted by this change. Existing navigation-target security restrictions still apply.
176
+
177
+ ## Full-resource qualification
178
+
179
+ The matching App integration fixture exercises actual producer commits through the authenticated Fleet gateway into Chromium, including remote read/deletion and a retained two-page inbox. Healthy delivery applies canonical payloads without content GETs after events; explicit and attended read/read-target/read-all POST conditions remain unchanged. Neutral observation does not enter foreground presence. Set `RESOURCE_QUALIFY_IDLE_MS=60000` for a sixty-second idle qualification. Backend service tests cover bounded chunks/output, per-user/epoch ownership and slow consumers; they do not establish every hosted deployment. Existing provider retry scheduling remains pending-work authority, independently of resource streams.
@@ -0,0 +1,25 @@
1
+ import type { ServerResponse } from 'node:http';
2
+ import type { Store } from './store.js';
3
+ /** Ephemeral user/credential-bound views; unrelated to foreground presence or acknowledgement. */
4
+ export declare class NotificationViews {
5
+ private readonly store;
6
+ readonly producerGeneration: `${string}-${string}-${string}-${string}-${string}`;
7
+ private readonly connections;
8
+ constructor(store: Store);
9
+ open(user: string, principal: string, response: ServerResponse): void;
10
+ private source;
11
+ private get;
12
+ private project;
13
+ private emit;
14
+ register(principal: string, input: Record<string, unknown>): {
15
+ subscriptionEpoch: number;
16
+ baselineId: string;
17
+ };
18
+ remove(principal: string, input: Record<string, unknown>): void;
19
+ bound(principal: string, connectionId: string, viewId: string, epoch: string, baselineId: string, resource: 'inbox' | 'summary'): {
20
+ producerGeneration: `${string}-${string}-${string}-${string}-${string}`;
21
+ payload: any;
22
+ revision: number;
23
+ };
24
+ close(): void;
25
+ }
@@ -0,0 +1,224 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { HttpError } from './validation.js';
3
+ const FRAME = 512 * 1024, PENDING = 8 * 1024 * 1024, ASSEMBLY = 16 * 1024 * 1024, CHUNK = 256 * 1024;
4
+ function descriptor(input) {
5
+ if (!input || typeof input !== 'object' || Array.isArray(input))
6
+ throw new HttpError(400, 'invalid resource descriptor');
7
+ const value = input;
8
+ if (Object.keys(value).some(k => !['viewId', 'resource', 'window'].includes(k)) || typeof value.viewId !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(value.viewId)
9
+ || !['summary', 'inbox'].includes(value.resource))
10
+ throw new HttpError(400, 'invalid resource descriptor');
11
+ if (value.resource === 'summary') {
12
+ if (value.window !== undefined)
13
+ throw new HttpError(400, 'summary has no window');
14
+ return { viewId: value.viewId, resource: value.resource };
15
+ }
16
+ const window = value.window;
17
+ if (!window || Object.keys(window).some(k => !['limit', 'retainedPages'].includes(k))
18
+ || !Number.isInteger(window.limit) || window.limit < 1 || window.limit > 100
19
+ || !Number.isInteger(window.retainedPages) || window.retainedPages < 1 || window.retainedPages > 200)
20
+ throw new HttpError(400, 'invalid inbox window');
21
+ return { viewId: value.viewId, resource: value.resource, window: { limit: window.limit, retainedPages: window.retainedPages } };
22
+ }
23
+ /** One active assembly is streamed lazily; waiting complete records share the smaller pending budget. */
24
+ class Output {
25
+ response;
26
+ failed;
27
+ active = false;
28
+ pending = [];
29
+ bytes = 0;
30
+ closed = false;
31
+ release;
32
+ constructor(response, failed) {
33
+ this.response = response;
34
+ this.failed = failed;
35
+ }
36
+ send(envelope) {
37
+ if (this.closed)
38
+ return;
39
+ const bytes = Buffer.byteLength(JSON.stringify(envelope));
40
+ if (bytes > ASSEMBLY || (this.active && this.bytes + bytes > PENDING)) {
41
+ this.failed();
42
+ return;
43
+ }
44
+ this.pending.push({ envelope, bytes });
45
+ this.bytes += bytes;
46
+ if (!this.active)
47
+ void this.pump();
48
+ }
49
+ close() { this.closed = true; this.pending = []; this.bytes = 0; this.release?.(); }
50
+ async write(event, data) {
51
+ if (this.closed)
52
+ return;
53
+ const text = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
54
+ if (Buffer.byteLength(text) > FRAME)
55
+ throw Error('resource frame limit');
56
+ if (!this.response.write(text))
57
+ await new Promise(resolve => {
58
+ const release = () => { this.response.removeListener('drain', release); this.release = undefined; resolve(); };
59
+ this.release = release;
60
+ this.response.once('drain', release);
61
+ });
62
+ }
63
+ async pump() {
64
+ this.active = true;
65
+ try {
66
+ while (!this.closed && this.pending.length) {
67
+ const { envelope, bytes } = this.pending.shift();
68
+ this.bytes -= bytes;
69
+ const encoded = Buffer.from(JSON.stringify(envelope));
70
+ if (encoded.length + 32 <= FRAME)
71
+ await this.write('resource', envelope);
72
+ else {
73
+ const binding = { protocolVersion: 1, id: `${envelope.viewId}:${envelope.subscriptionEpoch}:${envelope.sequence}`,
74
+ producerGeneration: envelope.producerGeneration, viewId: envelope.viewId, subscriptionEpoch: envelope.subscriptionEpoch,
75
+ revision: envelope.revision, sequence: envelope.sequence };
76
+ await this.write('resource-chunk', { ...binding, type: 'start', totalBytes: encoded.length, parts: Math.ceil(encoded.length / CHUNK) });
77
+ for (let offset = 0, index = 0; offset < encoded.length && !this.closed; offset += CHUNK, index++)
78
+ await this.write('resource-chunk', { ...binding, type: 'chunk', index, data: encoded.subarray(offset, offset + CHUNK).toString('base64') });
79
+ await this.write('resource-chunk', { ...binding, type: 'commit', totalBytes: encoded.length });
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ this.failed();
85
+ }
86
+ finally {
87
+ this.active = false;
88
+ }
89
+ }
90
+ }
91
+ // The global store revision is a read-command watermark, not user content.
92
+ // Keep it in published payloads, but do not emit another account's commit.
93
+ function contentKey(payload) {
94
+ return JSON.stringify({ ...payload, revision: undefined,
95
+ ...(payload.summary ? { summary: { ...payload.summary, revision: undefined } } : {}) });
96
+ }
97
+ /** Ephemeral user/credential-bound views; unrelated to foreground presence or acknowledgement. */
98
+ export class NotificationViews {
99
+ store;
100
+ producerGeneration = randomUUID();
101
+ connections = new Map();
102
+ constructor(store) {
103
+ this.store = store;
104
+ }
105
+ open(user, principal, response) {
106
+ if (this.connections.size >= 128 || [...this.connections.values()].filter(c => c.principal === principal).length >= 16)
107
+ throw new HttpError(503, 'resource capacity');
108
+ const id = randomUUID(), controller = new AbortController();
109
+ const close = () => {
110
+ if (controller.signal.aborted)
111
+ return;
112
+ controller.abort();
113
+ clearInterval(connection.timer);
114
+ connection.output.close();
115
+ connection.views.clear();
116
+ this.connections.delete(id);
117
+ response.removeListener('close', close);
118
+ response.end();
119
+ };
120
+ const output = new Output(response, close);
121
+ const timer = setInterval(() => {
122
+ for (const view of connection.views.values())
123
+ this.emit(connection, view, 'coverage', { source: this.source(view.revision) });
124
+ }, 2000);
125
+ timer.unref();
126
+ const connection = { id, principal, user, response, output, controller, views: new Map(), epochs: new Map(), timer, close };
127
+ this.connections.set(id, connection);
128
+ let scheduled = false;
129
+ this.store.subscribe(() => {
130
+ if (scheduled)
131
+ return;
132
+ scheduled = true;
133
+ queueMicrotask(() => {
134
+ scheduled = false;
135
+ if (controller.signal.aborted)
136
+ return;
137
+ try {
138
+ for (const view of connection.views.values()) {
139
+ const captured = this.project(user, view.descriptor), payload = contentKey(captured.payload);
140
+ if (payload === view.payload)
141
+ continue;
142
+ view.payload = payload;
143
+ view.revision = captured.revision;
144
+ this.emit(connection, view, 'snapshot', captured.payload);
145
+ this.emit(connection, view, 'coverage', { source: this.source(captured.revision) });
146
+ }
147
+ }
148
+ catch {
149
+ close();
150
+ }
151
+ });
152
+ }, controller.signal);
153
+ response.once('close', close);
154
+ response.setHeader('content-type', 'text/event-stream');
155
+ response.flushHeaders();
156
+ response.write(`event: resource-connection\ndata: ${JSON.stringify({ protocolVersion: 1, connectionId: id, producerGeneration: this.producerGeneration, baselineRequired: true, continuity: 'reset' })}\n\n`);
157
+ }
158
+ source(position) { return { protocolVersion: 1, producerGeneration: this.producerGeneration, position }; }
159
+ get(id, principal) {
160
+ const connection = this.connections.get(id);
161
+ if (!connection || connection.principal !== principal)
162
+ throw new HttpError(409, 'unknown resource connection');
163
+ return connection;
164
+ }
165
+ project(user, value) {
166
+ const summary = this.store.summary(user);
167
+ if (value.resource === 'summary')
168
+ return { payload: structuredClone(summary), revision: summary.revision };
169
+ const items = [];
170
+ let nextCursor = null;
171
+ for (let page = 0; page < value.window.retainedPages; page++) {
172
+ const next = this.store.inbox(user, nextCursor ?? undefined, value.window.limit);
173
+ items.push(...next.items);
174
+ nextCursor = next.nextCursor ?? null;
175
+ if (!nextCursor)
176
+ break;
177
+ }
178
+ return { payload: structuredClone({ items, unread: summary.unread, revision: summary.revision, nextCursor,
179
+ summary, delivery: this.store.deliveryStatus(user) }), revision: summary.revision };
180
+ }
181
+ emit(connection, view, kind, payload) {
182
+ connection.output.send({ protocolVersion: 1, producerGeneration: this.producerGeneration, viewId: view.descriptor.viewId,
183
+ subscriptionEpoch: view.epoch, sequence: ++view.sequence, revision: view.revision, baselineId: view.baselineId, kind, payload });
184
+ }
185
+ register(principal, input) {
186
+ const connection = this.get(String(input.connectionId), principal), value = descriptor(input.descriptor), epoch = input.subscriptionEpoch;
187
+ if (input.viewId !== value.viewId || !Number.isSafeInteger(epoch) || epoch < 1)
188
+ throw new HttpError(400, 'invalid subscription epoch');
189
+ if ((connection.epochs.get(value.viewId) ?? 0) >= epoch)
190
+ throw new HttpError(409, 'obsolete subscription epoch');
191
+ if ((!connection.views.has(value.viewId) && connection.views.size >= 32) || (!connection.epochs.has(value.viewId) && connection.epochs.size >= 128))
192
+ throw new HttpError(503, 'resource view capacity');
193
+ const baseline = this.project(connection.user, value);
194
+ const view = { descriptor: value, epoch: epoch, baselineId: randomUUID(), sequence: 0, revision: baseline.revision,
195
+ baseline, payload: contentKey(baseline.payload) };
196
+ connection.views.set(value.viewId, view);
197
+ connection.epochs.set(value.viewId, view.epoch);
198
+ this.emit(connection, view, 'snapshot', baseline.payload);
199
+ this.emit(connection, view, 'ready', { baselineId: view.baselineId, source: this.source(baseline.revision) });
200
+ return { subscriptionEpoch: view.epoch, baselineId: view.baselineId };
201
+ }
202
+ remove(principal, input) {
203
+ const connection = this.get(String(input.connectionId), principal), viewId = input.viewId, epoch = input.subscriptionEpoch;
204
+ if (typeof viewId !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(viewId)
205
+ || !Number.isSafeInteger(epoch) || epoch < 1)
206
+ throw new HttpError(400, 'invalid subscription epoch');
207
+ if (!connection.epochs.has(viewId) && connection.epochs.size >= 128)
208
+ throw new HttpError(503, 'resource view capacity');
209
+ // DELETE can beat an aborted registration POST over independent HTTP requests.
210
+ // Retain its fence even when the candidate has not been installed yet.
211
+ connection.epochs.set(viewId, Math.max(connection.epochs.get(viewId) ?? 0, epoch));
212
+ const view = connection.views.get(viewId);
213
+ if (view && epoch >= view.epoch)
214
+ connection.views.delete(viewId);
215
+ }
216
+ bound(principal, connectionId, viewId, epoch, baselineId, resource) {
217
+ const connection = this.get(connectionId, principal), view = connection.views.get(viewId);
218
+ if (!view || String(view.epoch) !== epoch || view.baselineId !== baselineId || view.descriptor.resource !== resource)
219
+ throw new HttpError(409, 'resource binding mismatch');
220
+ return { ...view.baseline, producerGeneration: this.producerGeneration };
221
+ }
222
+ close() { for (const connection of [...this.connections.values()])
223
+ connection.close(); }
224
+ }
package/dist/server.js CHANGED
@@ -3,6 +3,7 @@ import { timingSafeEqual } from 'node:crypto';
3
3
  import webpush from 'web-push';
4
4
  import { attachPresence } from './presence.js';
5
5
  import { Store } from './store.js';
6
+ import { NotificationViews } from './resource-views.js';
6
7
  import { HttpError, object, subscription, target, text } from './validation.js';
7
8
  const same = (a, b) => {
8
9
  const x = Buffer.from(a), y = Buffer.from(b);
@@ -31,6 +32,7 @@ export function createNotificationService(config, send) {
31
32
  throw new Error('invalid scoped credentials');
32
33
  webpush.setVapidDetails(config.vapid.subject, config.vapid.publicKey, config.vapid.privateKey);
33
34
  const store = new Store(config.stateFile);
35
+ const views = new NotificationViews(store);
34
36
  const deliver = send ?? ((sub, payload) => webpush.sendNotification(sub, payload, { TTL: 3600, timeout: 10_000,
35
37
  vapidDetails: config.vapid, contentEncoding: 'aes128gcm' }));
36
38
  const rates = new Map();
@@ -58,6 +60,38 @@ export function createNotificationService(config, send) {
58
60
  if (++rate.count > (producerRoute ? 600 : 180))
59
61
  throw new HttpError(429, 'rate limit exceeded');
60
62
  const user = credential.userId;
63
+ if (req.method === 'GET' && url.pathname === '/api/v1/events' && url.searchParams.get('resources') === '1') {
64
+ views.open(user, credential.token, res);
65
+ return;
66
+ }
67
+ if (req.method === 'POST' && url.pathname === '/api/v1/resource-subscriptions') {
68
+ const value = await body(req);
69
+ if (res.destroyed)
70
+ return;
71
+ res.end(JSON.stringify(views.register(credential.token, value)));
72
+ return;
73
+ }
74
+ if (req.method === 'DELETE' && url.pathname === '/api/v1/resource-subscriptions') {
75
+ const value = await body(req);
76
+ views.remove(credential.token, value);
77
+ res.end(JSON.stringify({ ok: true }));
78
+ return;
79
+ }
80
+ const binding = ['x-resource-connection', 'x-resource-view', 'x-resource-epoch', 'x-resource-baseline'].map(name => req.headers[name]);
81
+ if (binding.some(value => value !== undefined)) {
82
+ if (req.method !== 'GET' || !['/api/v1/summary', '/api/v1/inbox'].includes(url.pathname)
83
+ || binding.some(value => typeof value !== 'string') || url.searchParams.has('before'))
84
+ throw new HttpError(409, 'resource binding mismatch');
85
+ const baseline = views.bound(credential.token, binding[0], binding[1], binding[2], binding[3], url.pathname === '/api/v1/inbox' ? 'inbox' : 'summary');
86
+ for (const [index, name] of ['X-Resource-Connection', 'X-Resource-View', 'X-Resource-Epoch', 'X-Resource-Baseline'].entries())
87
+ res.setHeader(name, binding[index]);
88
+ res.setHeader('X-Resource-Producer', baseline.producerGeneration);
89
+ res.setHeader('X-Resource-Revision', String(baseline.revision));
90
+ res.setHeader('X-Resource-Source-Generation', baseline.producerGeneration);
91
+ res.setHeader('X-Resource-Source-Position', String(baseline.revision));
92
+ res.end(JSON.stringify(baseline.payload));
93
+ return;
94
+ }
61
95
  let result;
62
96
  if (req.method === 'GET' && url.pathname === '/api/v1/push/config')
63
97
  result = { publicKey: config.vapid.publicKey };
@@ -152,6 +186,7 @@ export function createNotificationService(config, send) {
152
186
  timer.unref();
153
187
  return { server, store, drain, async close() {
154
188
  clearInterval(timer);
189
+ views.close();
155
190
  presence.close();
156
191
  await draining;
157
192
  server.closeAllConnections();
package/dist/store.d.ts CHANGED
@@ -34,6 +34,9 @@ export declare class Store {
34
34
  private state;
35
35
  private readonly lockToken;
36
36
  private closed;
37
+ private readonly observers;
38
+ /** Neutral committed observation; never enrolls a device or changes read state. */
39
+ subscribe(committed: () => void, signal: AbortSignal): () => void;
37
40
  constructor(file: string);
38
41
  close(): void;
39
42
  private commit;
package/dist/store.js CHANGED
@@ -11,6 +11,16 @@ export class Store {
11
11
  state;
12
12
  lockToken = `${process.pid}:${randomUUID()}`;
13
13
  closed = false;
14
+ observers = new Set();
15
+ /** Neutral committed observation; never enrolls a device or changes read state. */
16
+ subscribe(committed, signal) {
17
+ const dispose = () => { this.observers.delete(committed); signal.removeEventListener('abort', dispose); };
18
+ if (!signal.aborted && !this.closed) {
19
+ this.observers.add(committed);
20
+ signal.addEventListener('abort', dispose, { once: true });
21
+ }
22
+ return dispose;
23
+ }
14
24
  constructor(file) {
15
25
  this.file = file;
16
26
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
@@ -48,6 +58,7 @@ export class Store {
48
58
  throw new Error('notification lock ownership changed');
49
59
  unlinkSync(this.file + '.lock');
50
60
  this.closed = true;
61
+ this.observers.clear();
51
62
  }
52
63
  commit(change) {
53
64
  const draft = structuredClone(this.state);
@@ -71,6 +82,13 @@ export class Store {
71
82
  closeSync(dir);
72
83
  }
73
84
  this.state = draft;
85
+ for (const committed of [...this.observers]) {
86
+ if (this.observers.has(committed))
87
+ try {
88
+ committed();
89
+ }
90
+ catch { /* committed persistence cannot be undone by observation */ }
91
+ }
74
92
  }
75
93
  ensureDevice(userId, subscription) {
76
94
  const existing = this.state.devices.find(d => d.subscription.endpoint === subscription.endpoint);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/notifications",
3
- "version": "0.3.1-nightly.4",
3
+ "version": "0.3.1-nightly.5",
4
4
  "description": "Authenticated web push delivery and shared notification inbox",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",