@ours.network/notifications 0.3.1-nightly.4 → 0.3.1-nightly.6
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 +16 -8
- package/dist/chat-target.d.ts +4 -7
- package/dist/chat-target.js +14 -29
- package/dist/resource-views.d.ts +25 -0
- package/dist/resource-views.js +224 -0
- package/dist/server.js +46 -6
- package/dist/store.d.ts +15 -6
- package/dist/store.js +136 -35
- package/dist/validation.d.ts +29 -1
- package/dist/validation.js +62 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,20 +56,20 @@ 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 | `{ "
|
|
59
|
+
| POST `/api/v1/read-target` | User | `{ "target": { "kind": "agent", "agentId": "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 | `{ "
|
|
63
|
-
| POST `/api/v1/send` | Producer | `{ "eventId": "stable-event-id", "title": "Agent completed", "body": "Open the chat", "
|
|
62
|
+
| POST `/api/v1/delete-target` | Producer | `{ "target": { "kind": "agent", "agentId": "agent" } }` → `{ "removed": COUNT }`; only its credential’s user/source and exact canonical conversation, ignoring message anchors |
|
|
63
|
+
| POST `/api/v1/send` | Producer | `{ "eventId": "stable-event-id", "title": "Agent completed", "body": "Open the chat", "target": { "kind": "agent", "agentId": "agent" } }` |
|
|
64
64
|
|
|
65
65
|
Inbox admission and queued delivery are one fsynced atomic snapshot. The service
|
|
66
|
-
deduplicates by user/source/event ID; replay must retain identical title/body/
|
|
66
|
+
deduplicates by user/source/event ID; replay must retain identical title/body, typed target/anchor and task association
|
|
67
67
|
or it receives 409. Inbox entries survive failed pushes and service restart.
|
|
68
68
|
Producer send payloads may include `taskId`: a stable task ID for an associated
|
|
69
69
|
ACP agent, or `null` for an explicitly standalone agent. Omission remains supported.
|
|
70
70
|
The association is persisted and cannot change when replaying the same event ID.
|
|
71
|
-
Summary section counts are independent of browser inventories: task-
|
|
72
|
-
|
|
71
|
+
Summary section counts are independent of browser inventories: task/task-deleted targets,
|
|
72
|
+
room targets and explicit task associations count under Tasks. An explicit
|
|
73
73
|
task association takes precedence over the source. Messenger destinations, including
|
|
74
74
|
room CIDs that open in Messenger, and explicit standalone agents count under Chats.
|
|
75
75
|
Legacy ACP notifications without association
|
|
@@ -79,7 +79,7 @@ counts with the same durable revision as the total.
|
|
|
79
79
|
|
|
80
80
|
Reading stops remaining queued deliveries. Deleting confirmed stale inbox records updates the shared revision, unread summary and presence subscribers atomically. Generic loading, permission or network failures do not establish target absence; callers must validate absence through the owning service. Device endpoints cannot transfer
|
|
81
81
|
between users, and only supported HTTPS push-provider hosts and valid P-256
|
|
82
|
-
subscription keys are accepted.
|
|
82
|
+
subscription keys are accepted. Only typed domain targets are accepted; browser address construction belongs to the App.
|
|
83
83
|
|
|
84
84
|
The service holds an exclusive state-file lock. Run one writer per state file.
|
|
85
85
|
After a crash, inspect the lock PID and prove no service is using this state
|
|
@@ -172,4 +172,12 @@ admitted as receipts on load. The existing inbox/job caps remain; a separate cap
|
|
|
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
174
|
|
|
175
|
-
|
|
175
|
+
Notifications carry domain identifiers, never frontend URLs. `target` is one of `{kind:"contact",contactId}`, `{kind:"agent",agentId}`, `{kind:"room",taskId,roomKey?}`, `{kind:"task",taskId}` or `{kind:"task-deleted",taskId}`. Omit `roomKey` for the keyless room. Optional `anchor` is `{kind:"messenger-message",wireId}` for contact/room targets or `{kind:"fleet-message",messageId}` for agents. IDs are raw strings, not URI-encoded by producers. Contact IDs are at most 512 characters, exclude controls, slashes/backslashes and dot segments; agent/task IDs are 1–120 ASCII letters/digits/underscores/hyphens. Room keys start with an ASCII letter, continue with letters/digits/underscores/hyphens up to 120 characters and exclude constructor/prototype/__proto__. Message IDs are nonempty opaque strings up to 1024 characters without controls. Object fields are closed; URL fields and mismatched anchors are rejected. Existing optional `taskId` task association remains and must agree with room/task targets when supplied.
|
|
176
|
+
|
|
177
|
+
Deploy producers, service, and App/worker together. Summary `chats` contains `{target,count}` conversation rows; anchors do not affect identity, 64-hex contact CIDs compare case-insensitively, and agent/task/room identities remain distinct. The App supplies its trusted workspace binding and encodes browser addresses. Send/read-target/delete-target accept only the typed contract; no old URL decoding or conversion is provided.
|
|
178
|
+
|
|
179
|
+
The state file records its format version. This release reads only its own version 2 state: a state file from an earlier release, or one with any record outside the typed contract, is refused at startup and left unchanged. A missing state file starts empty.
|
|
180
|
+
|
|
181
|
+
## Full-resource qualification
|
|
182
|
+
|
|
183
|
+
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,8 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
6
|
-
/** Stable conversation identity, independent of list filters and message anchors. */
|
|
7
|
-
export declare function chatTarget(path: string): ChatTarget | null;
|
|
1
|
+
import { type ConversationTarget } from './validation.js';
|
|
2
|
+
export type ChatTarget = ConversationTarget;
|
|
3
|
+
/** Stable conversation identity, independent of message anchors. */
|
|
4
|
+
export declare function chatTarget(value: unknown): ChatTarget | null;
|
|
8
5
|
export declare function chatKey(value: ChatTarget): string;
|
package/dist/chat-target.js
CHANGED
|
@@ -1,34 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
/** Stable conversation identity, independent of
|
|
3
|
-
export function chatTarget(
|
|
1
|
+
import { conversationTarget } from './validation.js';
|
|
2
|
+
/** Stable conversation identity, independent of message anchors. */
|
|
3
|
+
export function chatTarget(value) {
|
|
4
4
|
try {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
}
|
|
5
|
+
return conversationTarget(value);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
27
9
|
}
|
|
28
|
-
catch { /* Unsupported persisted input is unknown, never a missing target. */ }
|
|
29
|
-
return null;
|
|
30
10
|
}
|
|
31
11
|
export function chatKey(value) {
|
|
32
|
-
|
|
33
|
-
|
|
12
|
+
if (value.kind === 'contact') {
|
|
13
|
+
const id = /^[a-f0-9]{64}$/i.test(value.contactId) ? value.contactId.toUpperCase() : value.contactId;
|
|
14
|
+
return JSON.stringify(['contact', id]);
|
|
15
|
+
}
|
|
16
|
+
if (value.kind === 'agent')
|
|
17
|
+
return JSON.stringify(['agent', value.agentId]);
|
|
18
|
+
return JSON.stringify(['room', value.taskId, value.roomKey ?? null]);
|
|
34
19
|
}
|
|
@@ -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,7 +3,8 @@ 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 {
|
|
6
|
+
import { NotificationViews } from './resource-views.js';
|
|
7
|
+
import { HttpError, object, subscription, target, anchor, conversationTarget, fields, text } from './validation.js';
|
|
7
8
|
const same = (a, b) => {
|
|
8
9
|
const x = Buffer.from(a), y = Buffer.from(b);
|
|
9
10
|
return x.length === y.length && timingSafeEqual(x, y);
|
|
@@ -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 };
|
|
@@ -80,7 +114,8 @@ export function createNotificationService(config, send) {
|
|
|
80
114
|
result = { removed: store.deleteDevice(user, text((await body(req)).id, 100)) };
|
|
81
115
|
else if (req.method === 'POST' && url.pathname === '/api/v1/read-target') {
|
|
82
116
|
const value = await body(req);
|
|
83
|
-
|
|
117
|
+
fields(value, ['target', 'revision']);
|
|
118
|
+
store.readTarget(user, conversationTarget(value.target), value.revision);
|
|
84
119
|
result = { ok: true };
|
|
85
120
|
}
|
|
86
121
|
else if (req.method === 'POST' && url.pathname === '/api/v1/read') {
|
|
@@ -100,17 +135,21 @@ export function createNotificationService(config, send) {
|
|
|
100
135
|
const value = await body(req);
|
|
101
136
|
if ('userId' in value || 'source' in value)
|
|
102
137
|
throw new HttpError(400, 'recipient and source are credential scoped');
|
|
103
|
-
|
|
138
|
+
fields(value, ['target']);
|
|
139
|
+
result = { removed: store.deleteTarget(user, credential.source, conversationTarget(value.target)) };
|
|
104
140
|
}
|
|
105
141
|
else if (req.method === 'POST' && url.pathname === '/api/v1/send') {
|
|
106
142
|
const value = await body(req);
|
|
107
143
|
if ('userId' in value || 'source' in value)
|
|
108
144
|
throw new HttpError(400, 'recipient and source are credential scoped');
|
|
145
|
+
fields(value, ['eventId', 'title', 'body', 'target', 'anchor', 'taskId']);
|
|
146
|
+
const destination = target(value.target);
|
|
147
|
+
const messageAnchor = value.anchor === undefined ? undefined : anchor(value.anchor, destination);
|
|
109
148
|
const taskId = value.taskId === undefined ? undefined : value.taskId === null ? null : text(value.taskId, 120);
|
|
110
149
|
if (typeof taskId === 'string' && !/^[A-Za-z0-9_-]+$/.test(taskId))
|
|
111
150
|
throw new HttpError(400, 'invalid task identity');
|
|
112
151
|
result = store.produce({ ...(taskId === undefined ? {} : { taskId }), userId: user, source: credential.source, eventId: text(value.eventId, 512),
|
|
113
|
-
title: text(value.title, 160), body: text(value.body, 512),
|
|
152
|
+
title: text(value.title, 160), body: text(value.body, 512), target: destination, ...(messageAnchor === undefined ? {} : { anchor: messageAnchor }) });
|
|
114
153
|
}
|
|
115
154
|
else
|
|
116
155
|
throw new HttpError(404, 'route not found');
|
|
@@ -133,13 +172,13 @@ export function createNotificationService(config, send) {
|
|
|
133
172
|
store.finish(j.id, 'sent', Date.now(), j.notification?.readAt ? 'read_cancelled' : 'missing_target');
|
|
134
173
|
continue;
|
|
135
174
|
}
|
|
136
|
-
if (
|
|
175
|
+
if (j.notification.target?.kind !== 'task' && j.notification.target?.kind !== 'task-deleted' && presence.online(j.device.userId, j.device.id)) {
|
|
137
176
|
store.finish(j.id, 'sent', Date.now(), 'foreground_suppressed');
|
|
138
177
|
continue;
|
|
139
178
|
}
|
|
140
179
|
try {
|
|
141
180
|
const result = await deliver(j.device.subscription, JSON.stringify({ v: 1, kind: 'notification', title: j.notification.title,
|
|
142
|
-
body: j.notification.body,
|
|
181
|
+
body: j.notification.body, target: j.notification.target, ...(j.notification.anchor === undefined ? {} : { anchor: j.notification.anchor }), notification_id: j.notification.id, tag: j.notification.id }));
|
|
143
182
|
store.finish(j.id, 'sent', Date.now(), 'provider_accepted', result?.statusCode);
|
|
144
183
|
}
|
|
145
184
|
catch (error) {
|
|
@@ -152,6 +191,7 @@ export function createNotificationService(config, send) {
|
|
|
152
191
|
timer.unref();
|
|
153
192
|
return { server, store, drain, async close() {
|
|
154
193
|
clearInterval(timer);
|
|
194
|
+
views.close();
|
|
155
195
|
presence.close();
|
|
156
196
|
await draining;
|
|
157
197
|
server.closeAllConnections();
|
package/dist/store.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ChatTarget } from './chat-target.js';
|
|
2
2
|
import type { PushSubscription } from 'web-push';
|
|
3
|
+
import { type NotificationTarget, type NotificationAnchor } from './validation.js';
|
|
3
4
|
export interface Notification {
|
|
4
5
|
id: string;
|
|
5
6
|
userId: string;
|
|
@@ -7,7 +8,8 @@ export interface Notification {
|
|
|
7
8
|
eventId: string;
|
|
8
9
|
title: string;
|
|
9
10
|
body: string;
|
|
10
|
-
|
|
11
|
+
target: NotificationTarget;
|
|
12
|
+
anchor?: NotificationAnchor;
|
|
11
13
|
createdAt: string;
|
|
12
14
|
createdRevision?: number;
|
|
13
15
|
readAt: string | null;
|
|
@@ -28,12 +30,18 @@ interface Delivery {
|
|
|
28
30
|
attempt: number;
|
|
29
31
|
statusCode?: number;
|
|
30
32
|
}
|
|
33
|
+
type Input = Omit<Notification, 'id' | 'createdAt' | 'readAt'>;
|
|
34
|
+
/** The receipt fingerprint of a typed notification, as stored with it. */
|
|
35
|
+
export declare const fingerprint: (value: Input) => string;
|
|
31
36
|
/** Single-writer local service. Mutations commit a fsynced atomic snapshot before acknowledgement. */
|
|
32
37
|
export declare class Store {
|
|
33
38
|
private readonly file;
|
|
34
39
|
private state;
|
|
35
40
|
private readonly lockToken;
|
|
36
41
|
private closed;
|
|
42
|
+
private readonly observers;
|
|
43
|
+
/** Neutral committed observation; never enrolls a device or changes read state. */
|
|
44
|
+
subscribe(committed: () => void, signal: AbortSignal): () => void;
|
|
37
45
|
constructor(file: string);
|
|
38
46
|
close(): void;
|
|
39
47
|
private commit;
|
|
@@ -52,21 +60,22 @@ export declare class Store {
|
|
|
52
60
|
summary(userId: string): {
|
|
53
61
|
unread: number;
|
|
54
62
|
revision: number;
|
|
55
|
-
chats:
|
|
63
|
+
chats: {
|
|
64
|
+
target: ChatTarget;
|
|
56
65
|
count: number;
|
|
57
|
-
}
|
|
66
|
+
}[];
|
|
58
67
|
sections: {
|
|
59
68
|
chats: number;
|
|
60
69
|
tasks: number;
|
|
61
70
|
};
|
|
62
71
|
};
|
|
63
|
-
readTarget(userId: string,
|
|
72
|
+
readTarget(userId: string, value: unknown, revision: number): void;
|
|
64
73
|
read(userId: string, ids: string[]): void;
|
|
65
74
|
/** Delete only this user's confirmed stale entries and their pending deliveries. */
|
|
66
75
|
deleteNotifications(userId: string, ids: string[]): number;
|
|
67
76
|
/** A producer may retire only its own notifications for the canonical conversation. */
|
|
68
|
-
deleteTarget(userId: string, source: string,
|
|
69
|
-
produce(value:
|
|
77
|
+
deleteTarget(userId: string, source: string, value: unknown): number;
|
|
78
|
+
produce(value: Input): Notification | {
|
|
70
79
|
id: string;
|
|
71
80
|
duplicate: true;
|
|
72
81
|
};
|
package/dist/store.js
CHANGED
|
@@ -2,15 +2,115 @@ import { chatTarget, chatKey } from './chat-target.js';
|
|
|
2
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
|
-
import { HttpError } from './validation.js';
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import { HttpError, target, anchor, conversationTarget, subscription } from './validation.js';
|
|
6
|
+
/** The receipt fingerprint of a typed notification, as stored with it. */
|
|
7
|
+
export const fingerprint = (value) => createHash('sha256').update(JSON.stringify([
|
|
8
|
+
'domain-target-v1', value.userId, value.source, value.eventId, value.title, value.body,
|
|
9
|
+
target(value.target), value.anchor === undefined ? null : anchor(value.anchor, value.target),
|
|
10
|
+
value.taskId === undefined ? [] : [value.taskId],
|
|
11
|
+
])).digest('hex');
|
|
12
|
+
const outcomes = ['provider_accepted', 'foreground_suppressed', 'endpoint_expired', 'retry_scheduled', 'read_cancelled', 'missing_target', 'no_device'];
|
|
13
|
+
const record = (value, keys) => {
|
|
14
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || !Object.keys(value).every(key => keys.includes(key)))
|
|
15
|
+
throw new Error();
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
const string = (value) => { if (typeof value !== 'string' || !value)
|
|
19
|
+
throw new Error(); return value; };
|
|
20
|
+
const count = (value) => { if (!Number.isSafeInteger(value) || value < 0)
|
|
21
|
+
throw new Error(); return value; };
|
|
22
|
+
/** Accepts only this release's persisted format: every record typed, consistent and closed. */
|
|
23
|
+
function validState(value) {
|
|
24
|
+
try {
|
|
25
|
+
const state = record(value, ['version', 'revision', 'devices', 'notifications', 'jobs', 'receipts', 'deliveries']);
|
|
26
|
+
if (state.version !== 2)
|
|
27
|
+
throw new Error();
|
|
28
|
+
count(state.revision);
|
|
29
|
+
for (const key of ['devices', 'notifications', 'jobs', 'receipts', 'deliveries'])
|
|
30
|
+
if (!Array.isArray(state[key]))
|
|
31
|
+
throw new Error();
|
|
32
|
+
const devices = new Map(), notifications = new Map(), receipts = new Map();
|
|
33
|
+
for (const item of state.devices) {
|
|
34
|
+
const d = record(item, ['id', 'userId', 'subscription']);
|
|
35
|
+
subscription(d.subscription);
|
|
36
|
+
if (devices.has(string(d.id)))
|
|
37
|
+
throw new Error();
|
|
38
|
+
devices.set(d.id, string(d.userId));
|
|
39
|
+
}
|
|
40
|
+
for (const item of state.receipts) {
|
|
41
|
+
const r = record(item, ['userId', 'source', 'eventId', 'id', 'fingerprint']);
|
|
42
|
+
const key = JSON.stringify([string(r.userId), string(r.source), string(r.eventId)]);
|
|
43
|
+
string(r.id);
|
|
44
|
+
if (receipts.has(key) || !/^[a-f0-9]{64}$/.test(string(r.fingerprint)))
|
|
45
|
+
throw new Error();
|
|
46
|
+
receipts.set(key, r);
|
|
47
|
+
}
|
|
48
|
+
for (const item of state.notifications) {
|
|
49
|
+
const n = record(item, ['id', 'userId', 'source', 'eventId', 'title', 'body', 'target', 'anchor', 'createdAt', 'createdRevision', 'readAt', 'taskId']);
|
|
50
|
+
for (const key of ['id', 'userId', 'source', 'eventId', 'title', 'body', 'createdAt'])
|
|
51
|
+
string(n[key]);
|
|
52
|
+
const destination = target(n.target);
|
|
53
|
+
if (n.anchor !== undefined)
|
|
54
|
+
anchor(n.anchor, destination);
|
|
55
|
+
if (n.createdRevision !== undefined)
|
|
56
|
+
count(n.createdRevision);
|
|
57
|
+
if (n.readAt !== null)
|
|
58
|
+
string(n.readAt);
|
|
59
|
+
// Task association has the same rules as produce: a valid string identity that agrees with task and room targets.
|
|
60
|
+
if (n.taskId !== undefined && n.taskId !== null && (typeof n.taskId !== 'string' || !/^[A-Za-z0-9_-]{1,120}$/.test(n.taskId)))
|
|
61
|
+
throw new Error();
|
|
62
|
+
if ((destination.kind === 'task' || destination.kind === 'task-deleted' || destination.kind === 'room') && n.taskId !== undefined && n.taskId !== destination.taskId)
|
|
63
|
+
throw new Error();
|
|
64
|
+
// Each notification's own receipt carries its typed fingerprint.
|
|
65
|
+
const own = receipts.get(JSON.stringify([n.userId, n.source, n.eventId]));
|
|
66
|
+
if (!own || own.id !== n.id || own.fingerprint !== fingerprint(n) || notifications.has(n.id))
|
|
67
|
+
throw new Error();
|
|
68
|
+
notifications.set(n.id, n.userId);
|
|
69
|
+
}
|
|
70
|
+
for (const item of state.jobs) {
|
|
71
|
+
const j = record(item, ['id', 'notificationId', 'deviceId', 'attempts', 'due']);
|
|
72
|
+
string(j.id);
|
|
73
|
+
count(j.attempts);
|
|
74
|
+
count(j.due);
|
|
75
|
+
const owner = notifications.get(string(j.notificationId));
|
|
76
|
+
if (owner === undefined || devices.get(string(j.deviceId)) !== owner)
|
|
77
|
+
throw new Error();
|
|
78
|
+
}
|
|
79
|
+
for (const item of state.deliveries) {
|
|
80
|
+
const d = record(item, ['userId', 'notificationId', 'deviceId', 'at', 'outcome', 'attempt', 'statusCode']);
|
|
81
|
+
string(d.userId);
|
|
82
|
+
string(d.notificationId);
|
|
83
|
+
string(d.at);
|
|
84
|
+
count(d.attempt);
|
|
85
|
+
if (d.deviceId !== undefined)
|
|
86
|
+
string(d.deviceId);
|
|
87
|
+
if (d.statusCode !== undefined)
|
|
88
|
+
count(d.statusCode);
|
|
89
|
+
if (!outcomes.includes(d.outcome))
|
|
90
|
+
throw new Error();
|
|
91
|
+
}
|
|
92
|
+
return state;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new Error('unsupported notification state: this release reads only its own version 2 state file');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
8
98
|
/** Single-writer local service. Mutations commit a fsynced atomic snapshot before acknowledgement. */
|
|
9
99
|
export class Store {
|
|
10
100
|
file;
|
|
11
101
|
state;
|
|
12
102
|
lockToken = `${process.pid}:${randomUUID()}`;
|
|
13
103
|
closed = false;
|
|
104
|
+
observers = new Set();
|
|
105
|
+
/** Neutral committed observation; never enrolls a device or changes read state. */
|
|
106
|
+
subscribe(committed, signal) {
|
|
107
|
+
const dispose = () => { this.observers.delete(committed); signal.removeEventListener('abort', dispose); };
|
|
108
|
+
if (!signal.aborted && !this.closed) {
|
|
109
|
+
this.observers.add(committed);
|
|
110
|
+
signal.addEventListener('abort', dispose, { once: true });
|
|
111
|
+
}
|
|
112
|
+
return dispose;
|
|
113
|
+
}
|
|
14
114
|
constructor(file) {
|
|
15
115
|
this.file = file;
|
|
16
116
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
@@ -30,11 +130,9 @@ export class Store {
|
|
|
30
130
|
closeSync(lockFd);
|
|
31
131
|
}
|
|
32
132
|
try {
|
|
33
|
-
|
|
34
|
-
this.state
|
|
35
|
-
|
|
36
|
-
if (this.state.version !== 1 || !Array.isArray(this.state.notifications) || !Array.isArray(this.state.devices) || !Array.isArray(this.state.jobs))
|
|
37
|
-
throw new Error('invalid notification state');
|
|
133
|
+
// A missing state file starts empty; an existing one must be this release's format and is never rewritten to fit.
|
|
134
|
+
this.state = existsSync(file) ? validState(JSON.parse(readFileSync(file, 'utf8')))
|
|
135
|
+
: { version: 2, revision: 0, devices: [], notifications: [], jobs: [], receipts: [], deliveries: [] };
|
|
38
136
|
}
|
|
39
137
|
catch (error) {
|
|
40
138
|
this.close();
|
|
@@ -48,6 +146,7 @@ export class Store {
|
|
|
48
146
|
throw new Error('notification lock ownership changed');
|
|
49
147
|
unlinkSync(this.file + '.lock');
|
|
50
148
|
this.closed = true;
|
|
149
|
+
this.observers.clear();
|
|
51
150
|
}
|
|
52
151
|
commit(change) {
|
|
53
152
|
const draft = structuredClone(this.state);
|
|
@@ -71,6 +170,13 @@ export class Store {
|
|
|
71
170
|
closeSync(dir);
|
|
72
171
|
}
|
|
73
172
|
this.state = draft;
|
|
173
|
+
for (const committed of [...this.observers]) {
|
|
174
|
+
if (this.observers.has(committed))
|
|
175
|
+
try {
|
|
176
|
+
committed();
|
|
177
|
+
}
|
|
178
|
+
catch { /* committed persistence cannot be undone by observation */ }
|
|
179
|
+
}
|
|
74
180
|
}
|
|
75
181
|
ensureDevice(userId, subscription) {
|
|
76
182
|
const existing = this.state.devices.find(d => d.subscription.endpoint === subscription.endpoint);
|
|
@@ -110,36 +216,26 @@ export class Store {
|
|
|
110
216
|
const groups = new Map();
|
|
111
217
|
const sections = { chats: 0, tasks: 0 };
|
|
112
218
|
for (const n of unread) {
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
219
|
+
const destination = n.target;
|
|
220
|
+
if (destination.kind === 'task' || destination.kind === 'task-deleted' || destination.kind === 'room' || typeof n.taskId === 'string')
|
|
115
221
|
sections.tasks++;
|
|
116
|
-
else if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
sections.chats++;
|
|
121
|
-
else if (n.taskId === null)
|
|
122
|
-
sections.chats++;
|
|
123
|
-
// Older ACP records carry no association: leave them in the total only.
|
|
124
|
-
}
|
|
125
|
-
const target = chatTarget(n.url);
|
|
126
|
-
if (!target)
|
|
222
|
+
else if (destination.kind === 'contact' || n.taskId === null)
|
|
223
|
+
sections.chats++;
|
|
224
|
+
const conversation = chatTarget(destination);
|
|
225
|
+
if (!conversation)
|
|
127
226
|
continue;
|
|
128
|
-
const key = chatKey(
|
|
227
|
+
const key = chatKey(conversation), group = groups.get(key) ?? { target: conversation, count: 0 };
|
|
129
228
|
group.count++;
|
|
130
229
|
groups.set(key, group);
|
|
131
230
|
}
|
|
132
231
|
return { unread: unread.length, revision: this.state.revision, chats: [...groups.values()], sections };
|
|
133
232
|
}
|
|
134
|
-
readTarget(userId,
|
|
233
|
+
readTarget(userId, value, revision) {
|
|
135
234
|
if (!Number.isSafeInteger(revision) || revision < 0 || revision > this.state.revision)
|
|
136
235
|
throw new HttpError(400, 'invalid observed revision');
|
|
137
|
-
const
|
|
138
|
-
if (!target)
|
|
139
|
-
throw new HttpError(400, 'expected chat target');
|
|
140
|
-
const key = chatKey(target);
|
|
236
|
+
const key = chatKey(conversationTarget(value));
|
|
141
237
|
const ids = this.state.notifications.filter(n => n.userId === userId && !n.readAt && (n.createdRevision ?? 0) <= revision && (() => {
|
|
142
|
-
const other = chatTarget(n.
|
|
238
|
+
const other = chatTarget(n.target);
|
|
143
239
|
return other && chatKey(other) === key;
|
|
144
240
|
})()).map(n => n.id);
|
|
145
241
|
if (ids.length)
|
|
@@ -168,24 +264,29 @@ export class Store {
|
|
|
168
264
|
return removed.size;
|
|
169
265
|
}
|
|
170
266
|
/** A producer may retire only its own notifications for the canonical conversation. */
|
|
171
|
-
deleteTarget(userId, source,
|
|
172
|
-
const
|
|
173
|
-
if (!target)
|
|
174
|
-
throw new HttpError(400, 'expected chat target');
|
|
175
|
-
const key = chatKey(target);
|
|
267
|
+
deleteTarget(userId, source, value) {
|
|
268
|
+
const key = chatKey(conversationTarget(value));
|
|
176
269
|
const ids = this.state.notifications.filter(n => n.userId === userId && n.source === source && (() => {
|
|
177
|
-
const other = chatTarget(n.
|
|
270
|
+
const other = chatTarget(n.target);
|
|
178
271
|
return other && chatKey(other) === key;
|
|
179
272
|
})()).map(n => n.id);
|
|
180
273
|
return this.deleteNotifications(userId, ids);
|
|
181
274
|
}
|
|
182
275
|
produce(value) {
|
|
276
|
+
const destination = target(value.target);
|
|
277
|
+
if ('url' in value)
|
|
278
|
+
throw new HttpError(400, 'unexpected field');
|
|
279
|
+
value = { ...value, target: destination, ...(value.anchor === undefined ? {} : { anchor: anchor(value.anchor, destination) }) };
|
|
280
|
+
if (value.taskId !== undefined && value.taskId !== null && !/^[A-Za-z0-9_-]{1,120}$/.test(value.taskId))
|
|
281
|
+
throw new HttpError(400, 'invalid task identity');
|
|
282
|
+
if ((destination.kind === 'task' || destination.kind === 'task-deleted' || destination.kind === 'room') && value.taskId !== undefined && value.taskId !== destination.taskId)
|
|
283
|
+
throw new HttpError(400, 'conflicting task identity');
|
|
183
284
|
const receipt = this.state.receipts.find(r => r.userId === value.userId && r.source === value.source && r.eventId === value.eventId);
|
|
184
285
|
if (receipt && receipt.fingerprint !== fingerprint(value))
|
|
185
286
|
throw new HttpError(409, 'event id reused');
|
|
186
287
|
const previous = this.state.notifications.find(n => n.userId === value.userId && n.source === value.source && n.eventId === value.eventId);
|
|
187
288
|
if (previous) {
|
|
188
|
-
if (previous
|
|
289
|
+
if (fingerprint(previous) !== fingerprint(value))
|
|
189
290
|
throw new HttpError(409, 'event id reused');
|
|
190
291
|
return previous;
|
|
191
292
|
}
|
package/dist/validation.d.ts
CHANGED
|
@@ -5,5 +5,33 @@ export declare class HttpError extends Error {
|
|
|
5
5
|
}
|
|
6
6
|
export declare function text(value: unknown, max: number): string;
|
|
7
7
|
export declare function object(value: unknown): Record<string, unknown>;
|
|
8
|
-
export
|
|
8
|
+
export type ConversationTarget = {
|
|
9
|
+
kind: 'contact';
|
|
10
|
+
contactId: string;
|
|
11
|
+
} | {
|
|
12
|
+
kind: 'agent';
|
|
13
|
+
agentId: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'room';
|
|
16
|
+
taskId: string;
|
|
17
|
+
roomKey?: string;
|
|
18
|
+
};
|
|
19
|
+
export type NotificationTarget = ConversationTarget | {
|
|
20
|
+
kind: 'task';
|
|
21
|
+
taskId: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'task-deleted';
|
|
24
|
+
taskId: string;
|
|
25
|
+
};
|
|
26
|
+
export type NotificationAnchor = {
|
|
27
|
+
kind: 'messenger-message';
|
|
28
|
+
wireId: string;
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'fleet-message';
|
|
31
|
+
messageId: string;
|
|
32
|
+
};
|
|
33
|
+
export declare function fields(value: Record<string, unknown>, allowed: readonly string[]): void;
|
|
34
|
+
export declare function target(value: unknown): NotificationTarget;
|
|
35
|
+
export declare function conversationTarget(value: unknown): ConversationTarget;
|
|
36
|
+
export declare function anchor(value: unknown, destination: NotificationTarget): NotificationAnchor;
|
|
9
37
|
export declare function subscription(value: unknown): PushSubscription;
|
package/dist/validation.js
CHANGED
|
@@ -16,16 +16,69 @@ export function object(value) {
|
|
|
16
16
|
throw new HttpError(400, 'expected object');
|
|
17
17
|
return value;
|
|
18
18
|
}
|
|
19
|
+
export function fields(value, allowed) {
|
|
20
|
+
if (Object.keys(value).some(key => !allowed.includes(key)))
|
|
21
|
+
throw new HttpError(400, 'unexpected field');
|
|
22
|
+
}
|
|
23
|
+
function identifier(value, max) {
|
|
24
|
+
const id = text(value, max);
|
|
25
|
+
if (/[\x00-\x1f\x7f]/.test(id))
|
|
26
|
+
throw new HttpError(400, 'invalid identifier');
|
|
27
|
+
return id;
|
|
28
|
+
}
|
|
29
|
+
function taskIdentity(value) {
|
|
30
|
+
const id = identifier(value, 120);
|
|
31
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id))
|
|
32
|
+
throw new HttpError(400, 'invalid task identity');
|
|
33
|
+
return id;
|
|
34
|
+
}
|
|
19
35
|
export function target(value) {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
const item = object(value);
|
|
37
|
+
if (item.kind === 'contact') {
|
|
38
|
+
fields(item, ['kind', 'contactId']);
|
|
39
|
+
const contactId = identifier(item.contactId, 512);
|
|
40
|
+
if (/[\/\\]/.test(contactId) || contactId === '.' || contactId === '..')
|
|
41
|
+
throw new HttpError(400, 'invalid contact identity');
|
|
42
|
+
return { kind: 'contact', contactId };
|
|
43
|
+
}
|
|
44
|
+
if (item.kind === 'agent') {
|
|
45
|
+
fields(item, ['kind', 'agentId']);
|
|
46
|
+
const agentId = taskIdentity(item.agentId);
|
|
47
|
+
return { kind: 'agent', agentId };
|
|
48
|
+
}
|
|
49
|
+
if (item.kind === 'room') {
|
|
50
|
+
fields(item, ['kind', 'taskId', 'roomKey']);
|
|
51
|
+
const taskId = taskIdentity(item.taskId);
|
|
52
|
+
if (item.roomKey === undefined)
|
|
53
|
+
return { kind: 'room', taskId };
|
|
54
|
+
const roomKey = identifier(item.roomKey, 120);
|
|
55
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(roomKey) || ['constructor', 'prototype', '__proto__'].includes(roomKey))
|
|
56
|
+
throw new HttpError(400, 'invalid room key');
|
|
57
|
+
return { kind: 'room', taskId, roomKey };
|
|
58
|
+
}
|
|
59
|
+
if (item.kind === 'task' || item.kind === 'task-deleted') {
|
|
60
|
+
fields(item, ['kind', 'taskId']);
|
|
61
|
+
return { kind: item.kind, taskId: taskIdentity(item.taskId) };
|
|
62
|
+
}
|
|
63
|
+
throw new HttpError(400, 'invalid notification target');
|
|
64
|
+
}
|
|
65
|
+
export function conversationTarget(value) {
|
|
66
|
+
const result = target(value);
|
|
67
|
+
if (result.kind === 'task' || result.kind === 'task-deleted')
|
|
68
|
+
throw new HttpError(400, 'expected conversation target');
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
export function anchor(value, destination) {
|
|
72
|
+
const item = object(value);
|
|
73
|
+
if (item.kind === 'messenger-message' && (destination.kind === 'contact' || destination.kind === 'room')) {
|
|
74
|
+
fields(item, ['kind', 'wireId']);
|
|
75
|
+
return { kind: 'messenger-message', wireId: identifier(item.wireId, 1024) };
|
|
76
|
+
}
|
|
77
|
+
if (item.kind === 'fleet-message' && destination.kind === 'agent') {
|
|
78
|
+
fields(item, ['kind', 'messageId']);
|
|
79
|
+
return { kind: 'fleet-message', messageId: identifier(item.messageId, 1024) };
|
|
80
|
+
}
|
|
81
|
+
throw new HttpError(400, 'invalid notification anchor');
|
|
29
82
|
}
|
|
30
83
|
export function subscription(value) {
|
|
31
84
|
const item = object(value);
|
package/package.json
CHANGED