@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 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 | `{ "url": "/fleet/chats/agents/agent", "revision": OBSERVED_REVISION }`, reads only this chat up to the revision captured before its history load |
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 | `{ "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" }` |
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/URL
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-page URLs,
72
- encoded work task rooms and explicit task associations count under Tasks. An explicit
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. Navigation stays within `/fleet`.
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
- 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.
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
 
@@ -1,8 +1,5 @@
1
- export interface ChatTarget {
2
- kind: 'contact' | 'agent' | 'room';
3
- source: 'work' | 'messenger';
4
- chat: string;
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;
@@ -1,34 +1,19 @@
1
- import { target as navigationTarget } from './validation.js';
2
- /** Stable conversation identity, independent of list filters and message anchors. */
3
- export function chatTarget(path) {
1
+ import { conversationTarget } from './validation.js';
2
+ /** Stable conversation identity, independent of message anchors. */
3
+ export function chatTarget(value) {
4
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)))
8
- return null;
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
- 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]);
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
- store.readTarget(user, target(value.url), value.revision);
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
- result = { removed: store.deleteTarget(user, credential.source, target(value.url)) };
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), url: target(value.url) });
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 (!/^\/fleet\/tasks(?:\/[^/]+)?\/?$/.test(new URL(j.notification.url, 'https://ours.invalid').pathname) && presence.online(j.device.userId, j.device.id)) {
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, url: j.notification.url, notification_id: j.notification.id, tag: j.notification.id }));
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
- url: string;
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: (ChatTarget & {
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, url: string, revision: number): void;
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, url: string): number;
72
- produce(value: Omit<Notification, 'id' | 'createdAt' | 'readAt'>): Notification | {
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
- const fingerprint = (value) => createHash('sha256')
7
- .update(JSON.stringify([value.userId, value.source, value.eventId, value.title, value.body, value.url, value.taskId === undefined ? [] : [value.taskId]])).digest('hex');
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
- this.state = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { version: 1, revision: 0, devices: [], notifications: [], jobs: [] };
44
- this.state.receipts ??= this.state.notifications.map(n => ({ userId: n.userId, source: n.source, eventId: n.eventId, id: n.id, fingerprint: fingerprint(n) }));
45
- this.state.deliveries ??= [];
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 url = new URL(n.url, 'https://ours.invalid'), targetForSection = chatTarget(n.url);
132
- if (/^\/fleet\/tasks(?:\/[^/]+)?\/?$/.test(url.pathname))
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 (targetForSection) {
135
- if (typeof n.taskId === 'string' || targetForSection.kind === 'room')
136
- sections.tasks++;
137
- else if (targetForSection.source === 'messenger')
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(target), group = groups.get(key) ?? { ...target, count: 0 };
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, url, revision) {
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 target = chatTarget(url);
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.url);
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, url) {
190
- const target = chatTarget(url);
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.url);
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.title !== value.title || previous.body !== value.body || previous.url !== value.url || previous.taskId !== value.taskId)
289
+ if (fingerprint(previous) !== fingerprint(value))
207
290
  throw new HttpError(409, 'event id reused');
208
291
  return previous;
209
292
  }
@@ -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 declare function target(value: unknown): string;
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;
@@ -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 path = text(value, 2048);
21
- // Only first-party Fleet pages. No scheme-relative URLs, encoded slashes,
22
- // backslashes or control characters which browsers could normalize elsewhere.
23
- if (!/^\/fleet(?:\/|\?|$)/.test(path) || /[\\\x00-\x20]|%(?:2f|5c|00|0[ad])/i.test(path))
24
- throw new HttpError(400, 'invalid navigation target');
25
- const url = new URL(path, 'https://ours.invalid');
26
- if (url.origin !== 'https://ours.invalid' || !/^\/fleet(?:\/|$)/.test(url.pathname))
27
- throw new HttpError(400, 'invalid navigation target');
28
- return path;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/notifications",
3
- "version": "0.3.1-nightly.5",
3
+ "version": "0.3.1-nightly.6",
4
4
  "description": "Authenticated web push delivery and shared notification inbox",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",