@ours.network/notifications 0.3.1-nightly.3 → 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 +9 -3
- package/dist/chat-target.d.ts +6 -7
- package/dist/chat-target.js +27 -8
- package/dist/resource-views.d.ts +25 -0
- package/dist/resource-views.js +224 -0
- package/dist/server.js +35 -0
- package/dist/store.d.ts +6 -4
- package/dist/store.js +19 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,11 +56,11 @@ Responses are never cached. User and producer credentials have separate scopes.
|
|
|
56
56
|
| GET `/api/v1/devices/status?id=...` | User | `{ "active": true }` |
|
|
57
57
|
| GET `/api/v1/inbox?limit=50&before=...` | User | Items, shared unread count, revision, next cursor |
|
|
58
58
|
| GET `/api/v1/summary` | User | Shared unread count, revision, per-chat source/identity/count, and `sections: {chats, tasks}` |
|
|
59
|
-
| POST `/api/v1/read-target` | User | `{ "url": "/fleet/chats
|
|
59
|
+
| POST `/api/v1/read-target` | User | `{ "url": "/fleet/chats/agents/agent", "revision": OBSERVED_REVISION }`, reads only this chat up to the revision captured before its history load |
|
|
60
60
|
| POST `/api/v1/read` | User | `{ "ids": ["notification-id"] }`, idempotent shared read |
|
|
61
61
|
| POST `/api/v1/delete` | User | `{ "ids": ["notification-id"] }` → `{ "removed": COUNT }`; persistently removes only this user’s entries and queued deliveries after the client has confirmed target absence |
|
|
62
|
-
| POST `/api/v1/delete-target` | Producer | `{ "url": "/fleet/chats
|
|
63
|
-
| POST `/api/v1/send` | Producer | `{ "eventId": "stable-event-id", "title": "Agent completed", "body": "Open the chat", "url": "/fleet/chats
|
|
62
|
+
| POST `/api/v1/delete-target` | Producer | `{ "url": "/fleet/chats/agents/agent" }` → `{ "removed": COUNT }`; only its credential’s user/source and exact canonical conversation, including filter/anchor variants |
|
|
63
|
+
| POST `/api/v1/send` | Producer | `{ "eventId": "stable-event-id", "title": "Agent completed", "body": "Open the chat", "url": "/fleet/chats/agents/agent" }` |
|
|
64
64
|
|
|
65
65
|
Inbox admission and queued delivery are one fsynced atomic snapshot. The service
|
|
66
66
|
deduplicates by user/source/event ID; replay must retain identical title/body/URL
|
|
@@ -171,3 +171,9 @@ an inbox item is deleted, preventing retry resurrection. Legacy inbox entries ar
|
|
|
171
171
|
admitted as receipts on load. The existing inbox/job caps remain; a separate cap of
|
|
172
172
|
100,000 receipts per account rejects new events explicitly rather than evicting
|
|
173
173
|
dedupe evidence. Upgrades must preserve the service's state file and scoped credentials.
|
|
174
|
+
|
|
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.
|
package/dist/chat-target.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export interface ChatTarget {
|
|
2
|
+
kind: 'contact' | 'agent' | 'room';
|
|
3
3
|
source: 'work' | 'messenger';
|
|
4
4
|
chat: string;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}): string;
|
|
5
|
+
}
|
|
6
|
+
/** Stable conversation identity, independent of list filters and message anchors. */
|
|
7
|
+
export declare function chatTarget(path: string): ChatTarget | null;
|
|
8
|
+
export declare function chatKey(value: ChatTarget): string;
|
package/dist/chat-target.js
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
|
-
|
|
1
|
+
import { target as navigationTarget } from './validation.js';
|
|
2
|
+
/** Stable conversation identity, independent of list filters and message anchors. */
|
|
2
3
|
export function chatTarget(path) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
if (
|
|
4
|
+
try {
|
|
5
|
+
navigationTarget(path);
|
|
6
|
+
const url = new URL(path, 'https://ours.invalid');
|
|
7
|
+
if (['source', 'chat', 'task', 'detail'].some(key => url.searchParams.has(key)))
|
|
7
8
|
return null;
|
|
8
|
-
|
|
9
|
+
// Match the original path: URL normalization must not turn malformed arity into a target.
|
|
10
|
+
const parts = path.split(/[?#]/, 1)[0].split('/').slice(1).map(decodeURIComponent);
|
|
11
|
+
if (parts.some(part => !part || /[\x00-\x1f\x7f]/.test(part) || part === '.' || part === '..'))
|
|
12
|
+
return null;
|
|
13
|
+
if (parts.length === 4 && parts[0] === 'fleet' && parts[1] === 'chats') {
|
|
14
|
+
if (parts[2] === 'contacts')
|
|
15
|
+
return { kind: 'contact', source: 'messenger', chat: parts[3] };
|
|
16
|
+
if (parts[2] === 'agents')
|
|
17
|
+
return { kind: 'agent', source: 'work', chat: parts[3] };
|
|
18
|
+
}
|
|
19
|
+
if (parts[0] === 'fleet' && parts[1] === 'tasks' && /^[A-Za-z0-9_-]{1,120}$/.test(parts[2] ?? '')) {
|
|
20
|
+
if (parts.length === 4 && parts[3] === 'room')
|
|
21
|
+
return { kind: 'room', source: 'work', chat: `room-${parts[2]}` };
|
|
22
|
+
if (parts.length === 5 && parts[3] === 'rooms' && /^[A-Za-z][A-Za-z0-9_-]*$/.test(parts[4])
|
|
23
|
+
&& !['constructor', 'prototype', '__proto__'].includes(parts[4])) {
|
|
24
|
+
return { kind: 'room', source: 'work', chat: `room-${parts[2]}:${parts[4]}` };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
9
27
|
}
|
|
28
|
+
catch { /* Unsupported persisted input is unknown, never a missing target. */ }
|
|
10
29
|
return null;
|
|
11
30
|
}
|
|
12
31
|
export function chatKey(value) {
|
|
13
|
-
const chat = value.
|
|
14
|
-
return JSON.stringify([value.source, chat]);
|
|
32
|
+
const chat = value.kind === 'contact' && /^[a-f0-9]{64}$/i.test(value.chat) ? value.chat.toUpperCase() : value.chat;
|
|
33
|
+
return JSON.stringify([value.kind, value.source, chat]);
|
|
15
34
|
}
|
|
@@ -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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChatTarget } from './chat-target.js';
|
|
1
2
|
import type { PushSubscription } from 'web-push';
|
|
2
3
|
export interface Notification {
|
|
3
4
|
id: string;
|
|
@@ -33,6 +34,9 @@ export declare class Store {
|
|
|
33
34
|
private state;
|
|
34
35
|
private readonly lockToken;
|
|
35
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;
|
|
36
40
|
constructor(file: string);
|
|
37
41
|
close(): void;
|
|
38
42
|
private commit;
|
|
@@ -51,11 +55,9 @@ export declare class Store {
|
|
|
51
55
|
summary(userId: string): {
|
|
52
56
|
unread: number;
|
|
53
57
|
revision: number;
|
|
54
|
-
chats: {
|
|
55
|
-
source: "work" | "messenger";
|
|
56
|
-
chat: string;
|
|
58
|
+
chats: (ChatTarget & {
|
|
57
59
|
count: number;
|
|
58
|
-
}[];
|
|
60
|
+
})[];
|
|
59
61
|
sections: {
|
|
60
62
|
chats: number;
|
|
61
63
|
tasks: number;
|
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);
|
|
@@ -114,7 +132,7 @@ export class Store {
|
|
|
114
132
|
if (/^\/fleet\/tasks(?:\/[^/]+)?\/?$/.test(url.pathname))
|
|
115
133
|
sections.tasks++;
|
|
116
134
|
else if (targetForSection) {
|
|
117
|
-
if (typeof n.taskId === 'string' || targetForSection.
|
|
135
|
+
if (typeof n.taskId === 'string' || targetForSection.kind === 'room')
|
|
118
136
|
sections.tasks++;
|
|
119
137
|
else if (targetForSection.source === 'messenger')
|
|
120
138
|
sections.chats++;
|
package/package.json
CHANGED