@ours.network/notifications 0.3.1-nightly.1 → 0.3.1-nightly.2
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 -2
- package/dist/chat-target.js +4 -1
- package/dist/server.js +19 -4
- package/dist/store.d.ts +9 -0
- package/dist/store.js +39 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,15 +55,29 @@ Responses are never cached. User and producer credentials have separate scopes.
|
|
|
55
55
|
| POST `/api/v1/devices/delete` | User | `{ "id": "device-id" }`, only that user's device |
|
|
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
|
-
| GET `/api/v1/summary` | User | Shared unread count, revision,
|
|
58
|
+
| GET `/api/v1/summary` | User | Shared unread count, revision, per-chat source/identity/count, and `sections: {chats, tasks}` |
|
|
59
59
|
| POST `/api/v1/read-target` | User | `{ "url": "/fleet/chats?chat=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
|
+
| 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?chat=agent" }` → `{ "removed": COUNT }`; only its credential’s user/source and exact canonical conversation, including filter/anchor variants |
|
|
61
63
|
| POST `/api/v1/send` | Producer | `{ "eventId": "stable-event-id", "title": "Agent completed", "body": "Open the chat", "url": "/fleet/chats?chat=agent&detail=1" }` |
|
|
62
64
|
|
|
63
65
|
Inbox admission and queued delivery are one fsynced atomic snapshot. The service
|
|
64
66
|
deduplicates by user/source/event ID; replay must retain identical title/body/URL
|
|
65
67
|
or it receives 409. Inbox entries survive failed pushes and service restart.
|
|
66
|
-
|
|
68
|
+
Producer send payloads may include `taskId`: a stable task ID for an associated
|
|
69
|
+
ACP agent, or `null` for an explicitly standalone agent. Omission remains supported.
|
|
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
|
|
73
|
+
task association takes precedence over the source. Messenger destinations, including
|
|
74
|
+
room CIDs that open in Messenger, and explicit standalone agents count under Chats.
|
|
75
|
+
Legacy ACP notifications without association
|
|
76
|
+
and unknown destinations remain in the total and Notifications inbox only. Each
|
|
77
|
+
unread contributes at most once to a section. Reading or deletion updates these
|
|
78
|
+
counts with the same durable revision as the total.
|
|
79
|
+
|
|
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
|
|
67
81
|
between users, and only supported HTTPS push-provider hosts and valid P-256
|
|
68
82
|
subscription keys are accepted. Navigation stays within `/fleet`.
|
|
69
83
|
|
package/dist/chat-target.js
CHANGED
|
@@ -9,4 +9,7 @@ export function chatTarget(path) {
|
|
|
9
9
|
}
|
|
10
10
|
return null;
|
|
11
11
|
}
|
|
12
|
-
export function chatKey(value) {
|
|
12
|
+
export function chatKey(value) {
|
|
13
|
+
const chat = value.source === 'messenger' && /^[a-f0-9]{64}$/i.test(value.chat) ? value.chat.toUpperCase() : value.chat;
|
|
14
|
+
return JSON.stringify([value.source, chat]);
|
|
15
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -44,7 +44,7 @@ export function createNotificationService(config, send) {
|
|
|
44
44
|
if (req.headers.origin || req.headers.cookie || req.headers['sec-fetch-site'])
|
|
45
45
|
throw new HttpError(403, 'use authenticated gateway');
|
|
46
46
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
47
|
-
const producerRoute =
|
|
47
|
+
const producerRoute = ['/api/v1/send', '/api/v1/delete-target'].includes(url.pathname);
|
|
48
48
|
const authorization = req.headers.authorization ?? '';
|
|
49
49
|
const credential = (producerRoute ? config.producers : config.users).find(c => same(authorization, `Bearer ${c.token}`));
|
|
50
50
|
if (!credential)
|
|
@@ -88,16 +88,31 @@ export function createNotificationService(config, send) {
|
|
|
88
88
|
store.read(user, ids.map(id => text(id, 100)));
|
|
89
89
|
result = { ok: true };
|
|
90
90
|
}
|
|
91
|
-
else if (req.method === 'POST' &&
|
|
91
|
+
else if (req.method === 'POST' && url.pathname === '/api/v1/delete') {
|
|
92
|
+
const value = await body(req), ids = value.ids;
|
|
93
|
+
if ('userId' in value || 'source' in value || !Array.isArray(ids) || ids.length > 100)
|
|
94
|
+
throw new HttpError(400, 'invalid notification ids');
|
|
95
|
+
result = { removed: store.deleteNotifications(user, ids.map(id => text(id, 100))) };
|
|
96
|
+
}
|
|
97
|
+
else if (req.method === 'POST' && url.pathname === '/api/v1/delete-target') {
|
|
98
|
+
const value = await body(req);
|
|
99
|
+
if ('userId' in value || 'source' in value)
|
|
100
|
+
throw new HttpError(400, 'recipient and source are credential scoped');
|
|
101
|
+
result = { removed: store.deleteTarget(user, credential.source, target(value.url)) };
|
|
102
|
+
}
|
|
103
|
+
else if (req.method === 'POST' && url.pathname === '/api/v1/send') {
|
|
92
104
|
const value = await body(req);
|
|
93
105
|
if ('userId' in value || 'source' in value)
|
|
94
106
|
throw new HttpError(400, 'recipient and source are credential scoped');
|
|
95
|
-
|
|
107
|
+
const taskId = value.taskId === undefined ? undefined : value.taskId === null ? null : text(value.taskId, 120);
|
|
108
|
+
if (typeof taskId === 'string' && !/^[A-Za-z0-9_-]+$/.test(taskId))
|
|
109
|
+
throw new HttpError(400, 'invalid task identity');
|
|
110
|
+
result = store.produce({ ...(taskId === undefined ? {} : { taskId }), userId: user, source: credential.source, eventId: text(value.eventId, 512),
|
|
96
111
|
title: text(value.title, 160), body: text(value.body, 512), url: target(value.url) });
|
|
97
112
|
}
|
|
98
113
|
else
|
|
99
114
|
throw new HttpError(404, 'route not found');
|
|
100
|
-
if (req.method === 'POST' && ['/api/v1/send', '/api/v1/read', '/api/v1/read-target'].includes(url.pathname))
|
|
115
|
+
if (req.method === 'POST' && ['/api/v1/send', '/api/v1/read', '/api/v1/read-target', '/api/v1/delete', '/api/v1/delete-target'].includes(url.pathname))
|
|
101
116
|
presence.notify(user);
|
|
102
117
|
res.end(JSON.stringify(result));
|
|
103
118
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface Notification {
|
|
|
10
10
|
createdAt: string;
|
|
11
11
|
createdRevision?: number;
|
|
12
12
|
readAt: string | null;
|
|
13
|
+
taskId?: string | null;
|
|
13
14
|
}
|
|
14
15
|
interface Device {
|
|
15
16
|
id: string;
|
|
@@ -45,9 +46,17 @@ export declare class Store {
|
|
|
45
46
|
chat: string;
|
|
46
47
|
count: number;
|
|
47
48
|
}[];
|
|
49
|
+
sections: {
|
|
50
|
+
chats: number;
|
|
51
|
+
tasks: number;
|
|
52
|
+
};
|
|
48
53
|
};
|
|
49
54
|
readTarget(userId: string, url: string, revision: number): void;
|
|
50
55
|
read(userId: string, ids: string[]): void;
|
|
56
|
+
/** Delete only this user's confirmed stale entries and their pending deliveries. */
|
|
57
|
+
deleteNotifications(userId: string, ids: string[]): number;
|
|
58
|
+
/** A producer may retire only its own notifications for the canonical conversation. */
|
|
59
|
+
deleteTarget(userId: string, source: string, url: string): number;
|
|
51
60
|
produce(value: Omit<Notification, 'id' | 'createdAt' | 'readAt'>): Notification;
|
|
52
61
|
due(now: number): {
|
|
53
62
|
device: Device | undefined;
|
package/dist/store.js
CHANGED
|
@@ -104,7 +104,20 @@ export class Store {
|
|
|
104
104
|
summary(userId) {
|
|
105
105
|
const unread = this.state.notifications.filter(n => n.userId === userId && !n.readAt);
|
|
106
106
|
const groups = new Map();
|
|
107
|
+
const sections = { chats: 0, tasks: 0 };
|
|
107
108
|
for (const n of unread) {
|
|
109
|
+
const url = new URL(n.url, 'https://ours.invalid'), targetForSection = chatTarget(n.url);
|
|
110
|
+
if (/^\/fleet\/tasks\/[^/]+\/?$/.test(url.pathname))
|
|
111
|
+
sections.tasks++;
|
|
112
|
+
else if (targetForSection) {
|
|
113
|
+
if (typeof n.taskId === 'string' || targetForSection.source === 'work' && /^room-[^:]+(?::[^:]+)?$/.test(targetForSection.chat))
|
|
114
|
+
sections.tasks++;
|
|
115
|
+
else if (targetForSection.source === 'messenger')
|
|
116
|
+
sections.chats++;
|
|
117
|
+
else if (n.taskId === null)
|
|
118
|
+
sections.chats++;
|
|
119
|
+
// Older ACP records carry no association: leave them in the total only.
|
|
120
|
+
}
|
|
108
121
|
const target = chatTarget(n.url);
|
|
109
122
|
if (!target)
|
|
110
123
|
continue;
|
|
@@ -112,7 +125,7 @@ export class Store {
|
|
|
112
125
|
group.count++;
|
|
113
126
|
groups.set(key, group);
|
|
114
127
|
}
|
|
115
|
-
return { unread: unread.length, revision: this.state.revision, chats: [...groups.values()] };
|
|
128
|
+
return { unread: unread.length, revision: this.state.revision, chats: [...groups.values()], sections };
|
|
116
129
|
}
|
|
117
130
|
readTarget(userId, url, revision) {
|
|
118
131
|
if (!Number.isSafeInteger(revision) || revision < 0 || revision > this.state.revision)
|
|
@@ -138,10 +151,34 @@ export class Store {
|
|
|
138
151
|
s.jobs = s.jobs.filter(j => !s.notifications.find(n => n.id === j.notificationId)?.readAt);
|
|
139
152
|
});
|
|
140
153
|
}
|
|
154
|
+
/** Delete only this user's confirmed stale entries and their pending deliveries. */
|
|
155
|
+
deleteNotifications(userId, ids) {
|
|
156
|
+
const chosen = new Set(ids);
|
|
157
|
+
const removed = new Set(this.state.notifications.filter(n => n.userId === userId && chosen.has(n.id)).map(n => n.id));
|
|
158
|
+
if (!removed.size)
|
|
159
|
+
return 0;
|
|
160
|
+
this.commit(s => {
|
|
161
|
+
s.notifications = s.notifications.filter(n => !removed.has(n.id));
|
|
162
|
+
s.jobs = s.jobs.filter(j => !removed.has(j.notificationId));
|
|
163
|
+
});
|
|
164
|
+
return removed.size;
|
|
165
|
+
}
|
|
166
|
+
/** A producer may retire only its own notifications for the canonical conversation. */
|
|
167
|
+
deleteTarget(userId, source, url) {
|
|
168
|
+
const target = chatTarget(url);
|
|
169
|
+
if (!target)
|
|
170
|
+
throw new HttpError(400, 'expected chat target');
|
|
171
|
+
const key = chatKey(target);
|
|
172
|
+
const ids = this.state.notifications.filter(n => n.userId === userId && n.source === source && (() => {
|
|
173
|
+
const other = chatTarget(n.url);
|
|
174
|
+
return other && chatKey(other) === key;
|
|
175
|
+
})()).map(n => n.id);
|
|
176
|
+
return this.deleteNotifications(userId, ids);
|
|
177
|
+
}
|
|
141
178
|
produce(value) {
|
|
142
179
|
const previous = this.state.notifications.find(n => n.userId === value.userId && n.source === value.source && n.eventId === value.eventId);
|
|
143
180
|
if (previous) {
|
|
144
|
-
if (previous.title !== value.title || previous.body !== value.body || previous.url !== value.url)
|
|
181
|
+
if (previous.title !== value.title || previous.body !== value.body || previous.url !== value.url || previous.taskId !== value.taskId)
|
|
145
182
|
throw new HttpError(409, 'event id reused');
|
|
146
183
|
return previous;
|
|
147
184
|
}
|
package/package.json
CHANGED