@ours.network/notifications 0.3.1-nightly.5 → 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 +12 -8
- package/dist/chat-target.d.ts +4 -7
- package/dist/chat-target.js +14 -29
- package/dist/server.js +11 -6
- package/dist/store.d.ts +12 -6
- package/dist/store.js +118 -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,7 +172,11 @@ 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.
|
|
176
180
|
|
|
177
181
|
## Full-resource qualification
|
|
178
182
|
|
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
|
}
|
package/dist/server.js
CHANGED
|
@@ -4,7 +4,7 @@ import webpush from 'web-push';
|
|
|
4
4
|
import { attachPresence } from './presence.js';
|
|
5
5
|
import { Store } from './store.js';
|
|
6
6
|
import { NotificationViews } from './resource-views.js';
|
|
7
|
-
import { HttpError, object, subscription, target, text } from './validation.js';
|
|
7
|
+
import { HttpError, object, subscription, target, anchor, conversationTarget, fields, text } from './validation.js';
|
|
8
8
|
const same = (a, b) => {
|
|
9
9
|
const x = Buffer.from(a), y = Buffer.from(b);
|
|
10
10
|
return x.length === y.length && timingSafeEqual(x, y);
|
|
@@ -114,7 +114,8 @@ export function createNotificationService(config, send) {
|
|
|
114
114
|
result = { removed: store.deleteDevice(user, text((await body(req)).id, 100)) };
|
|
115
115
|
else if (req.method === 'POST' && url.pathname === '/api/v1/read-target') {
|
|
116
116
|
const value = await body(req);
|
|
117
|
-
|
|
117
|
+
fields(value, ['target', 'revision']);
|
|
118
|
+
store.readTarget(user, conversationTarget(value.target), value.revision);
|
|
118
119
|
result = { ok: true };
|
|
119
120
|
}
|
|
120
121
|
else if (req.method === 'POST' && url.pathname === '/api/v1/read') {
|
|
@@ -134,17 +135,21 @@ export function createNotificationService(config, send) {
|
|
|
134
135
|
const value = await body(req);
|
|
135
136
|
if ('userId' in value || 'source' in value)
|
|
136
137
|
throw new HttpError(400, 'recipient and source are credential scoped');
|
|
137
|
-
|
|
138
|
+
fields(value, ['target']);
|
|
139
|
+
result = { removed: store.deleteTarget(user, credential.source, conversationTarget(value.target)) };
|
|
138
140
|
}
|
|
139
141
|
else if (req.method === 'POST' && url.pathname === '/api/v1/send') {
|
|
140
142
|
const value = await body(req);
|
|
141
143
|
if ('userId' in value || 'source' in value)
|
|
142
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);
|
|
143
148
|
const taskId = value.taskId === undefined ? undefined : value.taskId === null ? null : text(value.taskId, 120);
|
|
144
149
|
if (typeof taskId === 'string' && !/^[A-Za-z0-9_-]+$/.test(taskId))
|
|
145
150
|
throw new HttpError(400, 'invalid task identity');
|
|
146
151
|
result = store.produce({ ...(taskId === undefined ? {} : { taskId }), userId: user, source: credential.source, eventId: text(value.eventId, 512),
|
|
147
|
-
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 }) });
|
|
148
153
|
}
|
|
149
154
|
else
|
|
150
155
|
throw new HttpError(404, 'route not found');
|
|
@@ -167,13 +172,13 @@ export function createNotificationService(config, send) {
|
|
|
167
172
|
store.finish(j.id, 'sent', Date.now(), j.notification?.readAt ? 'read_cancelled' : 'missing_target');
|
|
168
173
|
continue;
|
|
169
174
|
}
|
|
170
|
-
if (
|
|
175
|
+
if (j.notification.target?.kind !== 'task' && j.notification.target?.kind !== 'task-deleted' && presence.online(j.device.userId, j.device.id)) {
|
|
171
176
|
store.finish(j.id, 'sent', Date.now(), 'foreground_suppressed');
|
|
172
177
|
continue;
|
|
173
178
|
}
|
|
174
179
|
try {
|
|
175
180
|
const result = await deliver(j.device.subscription, JSON.stringify({ v: 1, kind: 'notification', title: j.notification.title,
|
|
176
|
-
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 }));
|
|
177
182
|
store.finish(j.id, 'sent', Date.now(), 'provider_accepted', result?.statusCode);
|
|
178
183
|
}
|
|
179
184
|
catch (error) {
|
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,6 +30,9 @@ 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;
|
|
@@ -55,21 +60,22 @@ export declare class Store {
|
|
|
55
60
|
summary(userId: string): {
|
|
56
61
|
unread: number;
|
|
57
62
|
revision: number;
|
|
58
|
-
chats:
|
|
63
|
+
chats: {
|
|
64
|
+
target: ChatTarget;
|
|
59
65
|
count: number;
|
|
60
|
-
}
|
|
66
|
+
}[];
|
|
61
67
|
sections: {
|
|
62
68
|
chats: number;
|
|
63
69
|
tasks: number;
|
|
64
70
|
};
|
|
65
71
|
};
|
|
66
|
-
readTarget(userId: string,
|
|
72
|
+
readTarget(userId: string, value: unknown, revision: number): void;
|
|
67
73
|
read(userId: string, ids: string[]): void;
|
|
68
74
|
/** Delete only this user's confirmed stale entries and their pending deliveries. */
|
|
69
75
|
deleteNotifications(userId: string, ids: string[]): number;
|
|
70
76
|
/** A producer may retire only its own notifications for the canonical conversation. */
|
|
71
|
-
deleteTarget(userId: string, source: string,
|
|
72
|
-
produce(value:
|
|
77
|
+
deleteTarget(userId: string, source: string, value: unknown): number;
|
|
78
|
+
produce(value: Input): Notification | {
|
|
73
79
|
id: string;
|
|
74
80
|
duplicate: true;
|
|
75
81
|
};
|
package/dist/store.js
CHANGED
|
@@ -2,9 +2,99 @@ 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;
|
|
@@ -40,11 +130,9 @@ export class Store {
|
|
|
40
130
|
closeSync(lockFd);
|
|
41
131
|
}
|
|
42
132
|
try {
|
|
43
|
-
|
|
44
|
-
this.state
|
|
45
|
-
|
|
46
|
-
if (this.state.version !== 1 || !Array.isArray(this.state.notifications) || !Array.isArray(this.state.devices) || !Array.isArray(this.state.jobs))
|
|
47
|
-
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: [] };
|
|
48
136
|
}
|
|
49
137
|
catch (error) {
|
|
50
138
|
this.close();
|
|
@@ -128,36 +216,26 @@ export class Store {
|
|
|
128
216
|
const groups = new Map();
|
|
129
217
|
const sections = { chats: 0, tasks: 0 };
|
|
130
218
|
for (const n of unread) {
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
219
|
+
const destination = n.target;
|
|
220
|
+
if (destination.kind === 'task' || destination.kind === 'task-deleted' || destination.kind === 'room' || typeof n.taskId === 'string')
|
|
133
221
|
sections.tasks++;
|
|
134
|
-
else if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
sections.chats++;
|
|
139
|
-
else if (n.taskId === null)
|
|
140
|
-
sections.chats++;
|
|
141
|
-
// Older ACP records carry no association: leave them in the total only.
|
|
142
|
-
}
|
|
143
|
-
const target = chatTarget(n.url);
|
|
144
|
-
if (!target)
|
|
222
|
+
else if (destination.kind === 'contact' || n.taskId === null)
|
|
223
|
+
sections.chats++;
|
|
224
|
+
const conversation = chatTarget(destination);
|
|
225
|
+
if (!conversation)
|
|
145
226
|
continue;
|
|
146
|
-
const key = chatKey(
|
|
227
|
+
const key = chatKey(conversation), group = groups.get(key) ?? { target: conversation, count: 0 };
|
|
147
228
|
group.count++;
|
|
148
229
|
groups.set(key, group);
|
|
149
230
|
}
|
|
150
231
|
return { unread: unread.length, revision: this.state.revision, chats: [...groups.values()], sections };
|
|
151
232
|
}
|
|
152
|
-
readTarget(userId,
|
|
233
|
+
readTarget(userId, value, revision) {
|
|
153
234
|
if (!Number.isSafeInteger(revision) || revision < 0 || revision > this.state.revision)
|
|
154
235
|
throw new HttpError(400, 'invalid observed revision');
|
|
155
|
-
const
|
|
156
|
-
if (!target)
|
|
157
|
-
throw new HttpError(400, 'expected chat target');
|
|
158
|
-
const key = chatKey(target);
|
|
236
|
+
const key = chatKey(conversationTarget(value));
|
|
159
237
|
const ids = this.state.notifications.filter(n => n.userId === userId && !n.readAt && (n.createdRevision ?? 0) <= revision && (() => {
|
|
160
|
-
const other = chatTarget(n.
|
|
238
|
+
const other = chatTarget(n.target);
|
|
161
239
|
return other && chatKey(other) === key;
|
|
162
240
|
})()).map(n => n.id);
|
|
163
241
|
if (ids.length)
|
|
@@ -186,24 +264,29 @@ export class Store {
|
|
|
186
264
|
return removed.size;
|
|
187
265
|
}
|
|
188
266
|
/** A producer may retire only its own notifications for the canonical conversation. */
|
|
189
|
-
deleteTarget(userId, source,
|
|
190
|
-
const
|
|
191
|
-
if (!target)
|
|
192
|
-
throw new HttpError(400, 'expected chat target');
|
|
193
|
-
const key = chatKey(target);
|
|
267
|
+
deleteTarget(userId, source, value) {
|
|
268
|
+
const key = chatKey(conversationTarget(value));
|
|
194
269
|
const ids = this.state.notifications.filter(n => n.userId === userId && n.source === source && (() => {
|
|
195
|
-
const other = chatTarget(n.
|
|
270
|
+
const other = chatTarget(n.target);
|
|
196
271
|
return other && chatKey(other) === key;
|
|
197
272
|
})()).map(n => n.id);
|
|
198
273
|
return this.deleteNotifications(userId, ids);
|
|
199
274
|
}
|
|
200
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');
|
|
201
284
|
const receipt = this.state.receipts.find(r => r.userId === value.userId && r.source === value.source && r.eventId === value.eventId);
|
|
202
285
|
if (receipt && receipt.fingerprint !== fingerprint(value))
|
|
203
286
|
throw new HttpError(409, 'event id reused');
|
|
204
287
|
const previous = this.state.notifications.find(n => n.userId === value.userId && n.source === value.source && n.eventId === value.eventId);
|
|
205
288
|
if (previous) {
|
|
206
|
-
if (previous
|
|
289
|
+
if (fingerprint(previous) !== fingerprint(value))
|
|
207
290
|
throw new HttpError(409, 'event id reused');
|
|
208
291
|
return previous;
|
|
209
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