@ours.network/notifications 0.3.1-nightly.7 → 1.0.1

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
@@ -50,17 +50,22 @@ Responses are never cached. User and producer credentials have separate scopes.
50
50
 
51
51
  | Method/path | Credential | Request/result |
52
52
  | --- | --- | --- |
53
- | GET `/api/v1/push/config` | User | Public VAPID key |
53
+ | GET `/api/v1/push/config` | User | Public VAPID key and `capabilities` (`push-only`, `mission-ownership`, `preferences`) |
54
+ | GET/POST `/api/v1/preferences` | User | `{ "ownMissionRoomMessages": false }`; default `true`. OFF is refused with 409 `mission_rooms_registering` unless the current bootstrap run is complete; the response includes `missionRoomsReady` |
54
55
  | POST `/api/v1/devices` | User | `{ "subscription": PushSubscriptionJSON }` → device `id` |
55
56
  | POST `/api/v1/devices/delete` | User | `{ "id": "device-id" }`, only that user's device |
56
57
  | GET `/api/v1/devices/status?id=...` | User | `{ "active": true }` |
57
58
  | 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, per-chat source/identity/count, and `sections: {chats, tasks}` |
59
+ | GET `/api/v1/unread-evidence?target=TARGET_JSON[&target=ALIAS_JSON]&revision=OBSERVED_REVISION&limit=100&after=...` | User | Capability `unread-evidence/1`: one conversation's records created at or before the observed revision and still unread, oldest first (limit 1-200). A record read since that revision is absent, so a caller comparing with that revision's count sees it as incomplete. Items omit title and body. A cursor whose record was deleted, or that this scope and revision could not have returned, answers 409. Services without the route answer 404 |
60
+ | GET `/api/v1/summary` | User | Shared unread count, revision, per-chat source/identity/count, and `sections: {chats, tasks}`; mission-associated rows (task and room targets, or any row with a `taskId`) count under `chats`, agent rows without a `taskId` field count in neither section (as before), and `tasks` is always 0 (kept for compatibility) |
59
61
  | 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
62
  | POST `/api/v1/read` | User | `{ "ids": ["notification-id"] }`, idempotent shared read |
61
63
  | 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
64
  | 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" } }` |
65
+ | POST `/api/v1/send` | Producer | `{ "eventId": "stable-event-id", "title": "Agent completed", "body": "Open the chat", "target": { "kind": "agent", "agentId": "agent" } }`, optional `"delivery": "inbox" \| "push-only"` |
66
+ | POST `/api/v1/missions/associate` | Producer with `mission-ownership` | `{ "taskId": "T", "revision": 2, "contactIds": ["ROOM-CID"] }` → retained rooms and revision |
67
+ | POST `/api/v1/missions/delete` | Producer with `mission-ownership` | `{ "taskId": "T", "contactIds": ["ROOM-CID"] }` → `{ "removed": COUNT }` |
68
+ | POST `/api/v1/missions/bootstrap` | Producer with `mission-ownership` | `{ "generation": 3, "complete": false }` starts a registration run of existing mission rooms; the same generation with `"complete": true` finishes it. Older generations get 409; a completed run cannot reopen |
64
69
 
65
70
  Inbox admission and queued delivery are one fsynced atomic snapshot. The service
66
71
  deduplicates by user/source/event ID; replay must retain identical title/body, typed target/anchor and task association
@@ -92,6 +97,30 @@ Limits are 32 devices/user, 10,000 inbox entries/user, 20,000 queued deliveries,
92
97
  returns 503 so durable producers can retain/retry events. This first version
93
98
  requires operator archival/reset after inbox capacity is reached.
94
99
 
100
+ ## Mission ownership, push-only delivery and preferences
101
+
102
+ `delivery: "push-only"` admits a row that is born read: it is pushed to every device but never counts as
103
+ unread. Reading never cancels its pending push; reading any other row still does. An admitted event keeps
104
+ its delivery mode: a retry may omit `delivery` (older producers) but a different explicit mode returns 409.
105
+
106
+ Only a producer credential configured with `capabilities: ["mission-ownership"]` (Fleet) may use the
107
+ `/api/v1/missions/*` routes, and only for its own credential user. An association lists the room contacts
108
+ the producer proved belong to one task. Revisions only advance; rooms are only added, so a removed room stays
109
+ attributable. `missions/delete` persists a tombstone and, in one durable revision, removes every row of that
110
+ user from any source whose task association or task/room target is the task, or whose contact is one of its
111
+ rooms, together with their queued pushes. Receipts stay. A later or replayed event for the mission records
112
+ its receipt and returns `200 { "id", "dropped": "deleted" }`; producers acknowledge it and nothing returns.
113
+ Repeating the deletion with more rooms merges and removes those too. Associations for a tombstoned task are
114
+ refused (409). Rows lacking both a task association and a proven room are never inferred from names or times.
115
+
116
+ `ownMissionRoomMessages: false` stops admitting messages from proven own mission rooms (receipt only,
117
+ `dropped: "suppressed"`), and in the same commit marks that user's existing unread own-room rows read and
118
+ removes their pushes. This is notification state only: conversation read receipts are not touched. Other
119
+ contacts and rooms, agent completions and push-only rows are unaffected, and switching back on never replays
120
+ suppressed events. Before every provider call the drain re-reads the job from the store, so a read,
121
+ deletion or preference change during an earlier send stops all later copies; a provider request already
122
+ started cannot be withdrawn, and an OS notification already shown cannot be removed by the service.
123
+
95
124
  ## Producers and PWA
96
125
 
97
126
  Fleet web and Messenger use `OURS_NOTIFICATIONS_ORIGIN` and their distinct
@@ -176,7 +205,7 @@ Notifications carry domain identifiers, never frontend URLs. `target` is one of
176
205
 
177
206
  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
207
 
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.
208
+ The state file records its format version. This release reads version 3 and strictly validated version 2 state; any other version, or any record outside the typed contract, is refused at startup and left unchanged. A missing state file starts empty. Version 2 is upgraded on load and written at once as version 3 under a strictly newer revision (row IDs, fingerprints and `createdRevision` unchanged; reopening does not bump again). Stored task lifecycle rows become read push-only rows and keep their valid pending pushes. A `task-deleted` row proves its task deleted: the task is tombstoned and every row it owns (lifecycle, task-associated agent and room rows) is removed with its pushes, so late events stay dropped.
180
209
 
181
210
  ## Full-resource qualification
182
211
 
@@ -0,0 +1,50 @@
1
+ /** Producer capability that may assert mission room ownership and retire a mission's notifications. */
2
+ export declare const MISSION_OWNERSHIP = "mission-ownership";
3
+ /** Advertised service capabilities; producers use new semantics only when listed. */
4
+ export declare const CAPABILITIES: readonly ["push-only", "mission-ownership", "preferences"];
5
+ /** Accumulated rooms one mission may retain; admission refuses beyond it, so stored state always reloads. */
6
+ export declare const MAX_MISSION_ROOMS = 10000;
7
+ /**
8
+ * Rooms one producer proved to belong to one of its user's missions. Contact IDs only grow:
9
+ * a removed room stays listed so its notifications remain attributable for deletion.
10
+ * A tombstone dominates every later association and admission for the mission.
11
+ */
12
+ export interface Mission {
13
+ userId: string;
14
+ source: string;
15
+ taskId: string;
16
+ revision: number;
17
+ contactIds: string[];
18
+ tombstone?: {
19
+ at: string;
20
+ };
21
+ }
22
+ export interface Preference {
23
+ userId: string;
24
+ ownMissionRoomMessages: boolean;
25
+ bootstrap: {
26
+ generation: number;
27
+ complete: boolean;
28
+ } | null;
29
+ }
30
+ export declare const contactKey: (id: string) => string;
31
+ export declare function taskId(value: unknown): string;
32
+ export declare function associationInput(value: Record<string, unknown>): {
33
+ taskId: string;
34
+ revision: number;
35
+ contactIds: string[];
36
+ };
37
+ export declare function deletionInput(value: Record<string, unknown>): {
38
+ taskId: string;
39
+ contactIds: string[];
40
+ };
41
+ export declare function preferenceInput(value: Record<string, unknown>): {
42
+ ownMissionRoomMessages: boolean;
43
+ };
44
+ export declare function bootstrapInput(value: Record<string, unknown>): {
45
+ generation: number;
46
+ complete: boolean;
47
+ };
48
+ /** Persisted-state validation for the mission records of a version 3 state file. */
49
+ export declare function validMissions(value: unknown): Mission[];
50
+ export declare function validPreferences(value: unknown): Preference[];
@@ -0,0 +1,94 @@
1
+ import { HttpError, fields, object, text } from './validation.js';
2
+ /** Producer capability that may assert mission room ownership and retire a mission's notifications. */
3
+ export const MISSION_OWNERSHIP = 'mission-ownership';
4
+ /** Advertised service capabilities; producers use new semantics only when listed. */
5
+ export const CAPABILITIES = ['push-only', MISSION_OWNERSHIP, 'preferences'];
6
+ const MAX_CONTACTS = 64;
7
+ /** Accumulated rooms one mission may retain; admission refuses beyond it, so stored state always reloads. */
8
+ export const MAX_MISSION_ROOMS = 10_000;
9
+ export const contactKey = (id) => /^[a-f0-9]{64}$/i.test(id) ? id.toUpperCase() : id;
10
+ export function taskId(value) {
11
+ const id = text(value, 120);
12
+ if (!/^[A-Za-z0-9_-]+$/.test(id))
13
+ throw new HttpError(400, 'invalid task identity');
14
+ return id;
15
+ }
16
+ function contactIds(value) {
17
+ if (!Array.isArray(value) || value.length > MAX_CONTACTS)
18
+ throw new HttpError(400, 'invalid contact identities');
19
+ return [...new Set(value.map(id => {
20
+ const contact = text(id, 512);
21
+ if (/[\x00-\x1f\x7f\/\\]/.test(contact) || contact === '.' || contact === '..')
22
+ throw new HttpError(400, 'invalid contact identity');
23
+ return contactKey(contact);
24
+ }))];
25
+ }
26
+ export function associationInput(value) {
27
+ fields(value, ['taskId', 'revision', 'contactIds']);
28
+ if (!Number.isSafeInteger(value.revision) || value.revision < 1)
29
+ throw new HttpError(400, 'invalid association revision');
30
+ return { taskId: taskId(value.taskId), revision: value.revision, contactIds: contactIds(value.contactIds) };
31
+ }
32
+ export function deletionInput(value) {
33
+ fields(value, ['taskId', 'contactIds']);
34
+ return { taskId: taskId(value.taskId), contactIds: contactIds(value.contactIds ?? []) };
35
+ }
36
+ export function preferenceInput(value) {
37
+ fields(value, ['ownMissionRoomMessages']);
38
+ if (typeof value.ownMissionRoomMessages !== 'boolean')
39
+ throw new HttpError(400, 'invalid preference');
40
+ return { ownMissionRoomMessages: value.ownMissionRoomMessages };
41
+ }
42
+ export function bootstrapInput(value) {
43
+ fields(value, ['generation', 'complete']);
44
+ if (!Number.isSafeInteger(value.generation) || value.generation < 1 || typeof value.complete !== 'boolean')
45
+ throw new HttpError(400, 'invalid bootstrap');
46
+ return { generation: value.generation, complete: value.complete };
47
+ }
48
+ /** Persisted-state validation for the mission records of a version 3 state file. */
49
+ export function validMissions(value) {
50
+ if (!Array.isArray(value))
51
+ throw new Error();
52
+ const keys = new Set();
53
+ for (const item of value) {
54
+ const m = object(item);
55
+ if (Object.keys(m).some(k => !['userId', 'source', 'taskId', 'revision', 'contactIds', 'tombstone'].includes(k)))
56
+ throw new Error();
57
+ if (typeof m.userId !== 'string' || !m.userId || typeof m.source !== 'string' || !m.source)
58
+ throw new Error();
59
+ taskId(m.taskId);
60
+ if (!Number.isSafeInteger(m.revision) || m.revision < 0)
61
+ throw new Error();
62
+ // Persisted rooms obey the same identity rules as admission: canonical, unique, no blanks, controls, paths or dot segments.
63
+ if (!Array.isArray(m.contactIds) || new Set(m.contactIds).size !== m.contactIds.length || m.contactIds.length > MAX_MISSION_ROOMS
64
+ || m.contactIds.some(id => typeof id !== 'string' || !id.trim() || id.length > 512 || /[\x00-\x1f\x7f\/\\]/.test(id)
65
+ || id === '.' || id === '..' || contactKey(id) !== id))
66
+ throw new Error();
67
+ if (m.tombstone !== undefined && (typeof object(m.tombstone).at !== 'string' || Object.keys(m.tombstone).length !== 1))
68
+ throw new Error();
69
+ const key = JSON.stringify([m.userId, m.source, m.taskId]);
70
+ if (keys.has(key))
71
+ throw new Error();
72
+ keys.add(key);
73
+ }
74
+ return value;
75
+ }
76
+ export function validPreferences(value) {
77
+ if (!Array.isArray(value))
78
+ throw new Error();
79
+ const users = new Set();
80
+ for (const item of value) {
81
+ const p = object(item);
82
+ if (Object.keys(p).sort().join() !== 'bootstrap,ownMissionRoomMessages,userId')
83
+ throw new Error();
84
+ if (typeof p.userId !== 'string' || !p.userId || users.has(p.userId) || typeof p.ownMissionRoomMessages !== 'boolean')
85
+ throw new Error();
86
+ if (p.bootstrap !== null) {
87
+ const b = object(p.bootstrap);
88
+ if (Object.keys(b).sort().join() !== 'complete,generation' || !Number.isSafeInteger(b.generation) || b.generation < 1 || typeof b.complete !== 'boolean')
89
+ throw new Error();
90
+ }
91
+ users.add(p.userId);
92
+ }
93
+ return value;
94
+ }
package/dist/server.d.ts CHANGED
@@ -4,7 +4,8 @@ import { Store } from './store.js';
4
4
  export interface Credential {
5
5
  token: string;
6
6
  userId: string;
7
- source?: string;
7
+ source?: string; /** Producer capabilities, e.g. `mission-ownership` for Fleet. */
8
+ capabilities?: string[];
8
9
  }
9
10
  export interface Config {
10
11
  host?: string;
package/dist/server.js CHANGED
@@ -5,6 +5,7 @@ import { attachPresence } from './presence.js';
5
5
  import { Store } from './store.js';
6
6
  import { NotificationViews } from './resource-views.js';
7
7
  import { HttpError, object, subscription, target, anchor, conversationTarget, fields, text } from './validation.js';
8
+ import { CAPABILITIES, MISSION_OWNERSHIP, associationInput, bootstrapInput, deletionInput, preferenceInput } from './mission.js';
8
9
  const same = (a, b) => {
9
10
  const x = Buffer.from(a), y = Buffer.from(b);
10
11
  return x.length === y.length && timingSafeEqual(x, y);
@@ -28,10 +29,17 @@ async function body(req) {
28
29
  export function createNotificationService(config, send) {
29
30
  const tokens = [...config.users, ...config.producers];
30
31
  if (!tokens.length || tokens.some(c => typeof c.token !== 'string' || c.token.length < 32 || !c.userId || c.userId.length > 256)
31
- || new Set(tokens.map(c => c.token)).size !== tokens.length || config.producers.some(c => !c.source))
32
+ || new Set(tokens.map(c => c.token)).size !== tokens.length || config.producers.some(c => !c.source)
33
+ || tokens.some(c => c.capabilities !== undefined && (!Array.isArray(c.capabilities) || c.capabilities.some(v => typeof v !== 'string'))))
32
34
  throw new Error('invalid scoped credentials');
33
35
  webpush.setVapidDetails(config.vapid.subject, config.vapid.publicKey, config.vapid.privateKey);
34
- const store = new Store(config.stateFile);
36
+ // Trust is per (user, source) pair. A pair whose credentials disagree on mission ownership is refused: its typed task
37
+ // events would otherwise be push-only or inbox depending on which token sent them.
38
+ const trusted = (c) => !!c.capabilities?.includes(MISSION_OWNERSHIP);
39
+ for (const c of config.producers)
40
+ if (config.producers.some(o => o.userId === c.userId && o.source === c.source && trusted(o) !== trusted(c)))
41
+ throw new Error('invalid scoped credentials: one producer has credentials with and without mission ownership');
42
+ const store = new Store(config.stateFile, { lifecycleProducers: config.producers.filter(trusted).map(c => ({ userId: c.userId, source: c.source })) });
35
43
  const views = new NotificationViews(store);
36
44
  const deliver = send ?? ((sub, payload) => webpush.sendNotification(sub, payload, { TTL: 3600, timeout: 10_000,
37
45
  vapidDetails: config.vapid, contentEncoding: 'aes128gcm' }));
@@ -46,11 +54,15 @@ export function createNotificationService(config, send) {
46
54
  if (req.headers.origin || req.headers.cookie || req.headers['sec-fetch-site'])
47
55
  throw new HttpError(403, 'use authenticated gateway');
48
56
  const url = new URL(req.url ?? '/', 'http://localhost');
49
- const producerRoute = ['/api/v1/send', '/api/v1/delete-target'].includes(url.pathname);
57
+ const missionRoute = url.pathname.startsWith('/api/v1/missions/');
58
+ const producerRoute = ['/api/v1/send', '/api/v1/delete-target'].includes(url.pathname) || missionRoute;
50
59
  const authorization = req.headers.authorization ?? '';
51
60
  const credential = (producerRoute ? config.producers : config.users).find(c => same(authorization, `Bearer ${c.token}`));
52
61
  if (!credential)
53
62
  throw new HttpError(401, 'authentication required');
63
+ // Cross-source mission cleanup is reserved to the producer trusted with mission ownership, for its own user.
64
+ if (missionRoute && !credential.capabilities?.includes(MISSION_OWNERSHIP))
65
+ throw new HttpError(403, 'mission ownership capability required');
54
66
  const key = credential.token;
55
67
  let rate = rates.get(key);
56
68
  if (!rate || rate.until <= Date.now()) {
@@ -94,7 +106,26 @@ export function createNotificationService(config, send) {
94
106
  }
95
107
  let result;
96
108
  if (req.method === 'GET' && url.pathname === '/api/v1/push/config')
97
- result = { publicKey: config.vapid.publicKey };
109
+ result = { publicKey: config.vapid.publicKey, capabilities: CAPABILITIES };
110
+ else if (req.method === 'GET' && url.pathname === '/api/v1/preferences')
111
+ result = store.preference(user);
112
+ else if (req.method === 'POST' && url.pathname === '/api/v1/preferences')
113
+ result = store.setPreference(user, preferenceInput(await body(req)).ownMissionRoomMessages);
114
+ else if (req.method === 'POST' && url.pathname === '/api/v1/missions/associate') {
115
+ const value = await body(req);
116
+ if ('userId' in value || 'source' in value)
117
+ throw new HttpError(400, 'recipient and source are credential scoped');
118
+ result = store.associateMission(user, credential.source, associationInput(value));
119
+ }
120
+ else if (req.method === 'POST' && url.pathname === '/api/v1/missions/delete') {
121
+ const value = await body(req);
122
+ if ('userId' in value || 'source' in value)
123
+ throw new HttpError(400, 'recipient and source are credential scoped');
124
+ result = store.deleteMission(user, credential.source, deletionInput(value));
125
+ }
126
+ else if (req.method === 'POST' && url.pathname === '/api/v1/missions/bootstrap') {
127
+ result = store.missionBootstrap(user, bootstrapInput(await body(req)));
128
+ }
98
129
  else if (req.method === 'GET' && url.pathname === '/api/v1/delivery/status')
99
130
  result = store.deliveryStatus(user);
100
131
  else if (req.method === 'GET' && url.pathname === '/api/v1/summary')
@@ -105,6 +136,26 @@ export function createNotificationService(config, send) {
105
136
  throw new HttpError(400, 'invalid limit');
106
137
  result = store.inbox(user, url.searchParams.get('before') ?? undefined, limit);
107
138
  }
139
+ else if (req.method === 'GET' && url.pathname === '/api/v1/unread-evidence') {
140
+ // Capability `unread-evidence/1`: a separate route, so a service without it answers
141
+ // 404 instead of ignoring the scope and returning the global inbox.
142
+ const targets = url.searchParams.getAll('target');
143
+ if (targets.length < 1 || targets.length > 2)
144
+ throw new HttpError(400, 'expected one or two conversation targets');
145
+ const scope = targets.map(value => { try {
146
+ return conversationTarget(JSON.parse(value));
147
+ }
148
+ catch (error) {
149
+ throw error instanceof HttpError ? error : new HttpError(400, 'invalid conversation target');
150
+ } });
151
+ const limit = Number(url.searchParams.get('limit') ?? 100);
152
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200)
153
+ throw new HttpError(400, 'invalid limit');
154
+ if (!url.searchParams.has('revision'))
155
+ throw new HttpError(400, 'invalid observed revision');
156
+ const revision = Number(url.searchParams.get('revision'));
157
+ result = { capability: 'unread-evidence/1', targets: scope, ...store.unreadEvidence(user, scope, revision, url.searchParams.get('after') ?? undefined, limit) };
158
+ }
108
159
  else if (req.method === 'GET' && url.pathname === '/api/v1/devices/status')
109
160
  result = { active: store.deviceStatus(user, url.searchParams.get('id') ?? '') };
110
161
  else if (req.method === 'POST' && url.pathname === '/api/v1/devices') {
@@ -142,18 +193,20 @@ export function createNotificationService(config, send) {
142
193
  const value = await body(req);
143
194
  if ('userId' in value || 'source' in value)
144
195
  throw new HttpError(400, 'recipient and source are credential scoped');
145
- fields(value, ['eventId', 'title', 'body', 'target', 'anchor', 'taskId']);
196
+ fields(value, ['eventId', 'title', 'body', 'target', 'anchor', 'taskId', 'delivery']);
197
+ if (value.delivery !== undefined && value.delivery !== 'inbox' && value.delivery !== 'push-only')
198
+ throw new HttpError(400, 'invalid delivery');
146
199
  const destination = target(value.target);
147
200
  const messageAnchor = value.anchor === undefined ? undefined : anchor(value.anchor, destination);
148
201
  const taskId = value.taskId === undefined ? undefined : value.taskId === null ? null : text(value.taskId, 120);
149
202
  if (typeof taskId === 'string' && !/^[A-Za-z0-9_-]+$/.test(taskId))
150
203
  throw new HttpError(400, 'invalid task identity');
151
- result = store.produce({ ...(taskId === undefined ? {} : { taskId }), userId: user, source: credential.source, eventId: text(value.eventId, 512),
204
+ result = store.produce({ ...(taskId === undefined ? {} : { taskId }), ...(value.delivery === undefined ? {} : { pushOnly: value.delivery === 'push-only' }), userId: user, source: credential.source, eventId: text(value.eventId, 512),
152
205
  title: text(value.title, 160), body: text(value.body, 512), target: destination, ...(messageAnchor === undefined ? {} : { anchor: messageAnchor }) });
153
206
  }
154
207
  else
155
208
  throw new HttpError(404, 'route not found');
156
- if (req.method === 'POST' && ['/api/v1/send', '/api/v1/read', '/api/v1/read-target', '/api/v1/delete', '/api/v1/delete-target'].includes(url.pathname))
209
+ if (req.method === 'POST' && (missionRoute || ['/api/v1/send', '/api/v1/read', '/api/v1/read-target', '/api/v1/delete', '/api/v1/delete-target', '/api/v1/preferences'].includes(url.pathname)))
157
210
  presence.notify(user);
158
211
  res.end(JSON.stringify(result));
159
212
  }
@@ -167,9 +220,15 @@ export function createNotificationService(config, send) {
167
220
  server.headersTimeout = 10_000;
168
221
  let draining;
169
222
  const drain = () => draining ??= (async () => {
170
- for (const j of store.due(Date.now())) {
171
- if (!j.device || !j.notification || j.notification.readAt) {
172
- store.finish(j.id, 'sent', Date.now(), j.notification?.readAt ? 'read_cancelled' : 'missing_target');
223
+ // The due snapshot only nominates jobs. Each is re-read from the store right before its provider call, so a
224
+ // read, deletion, tombstone or preference change during an earlier send stops every later copy.
225
+ for (const due of store.due(Date.now())) {
226
+ const current = store.dispatchable(due.id);
227
+ if (!current)
228
+ continue;
229
+ const j = { ...current.job, device: current.device, notification: current.notification };
230
+ if (!j.device || !j.notification || current.cancelled) {
231
+ store.finish(j.id, 'sent', Date.now(), current.cancelled ? 'read_cancelled' : 'missing_target');
173
232
  continue;
174
233
  }
175
234
  if (j.notification.target?.kind !== 'task' && j.notification.target?.kind !== 'task-deleted' && presence.online(j.device.userId, j.device.id)) {
package/dist/store.d.ts CHANGED
@@ -14,12 +14,21 @@ export interface Notification {
14
14
  createdRevision?: number;
15
15
  readAt: string | null;
16
16
  taskId?: string | null;
17
+ /** Born read: delivered as push, never unread in the app. */
18
+ pushOnly?: true;
17
19
  }
18
20
  interface Device {
19
21
  id: string;
20
22
  userId: string;
21
23
  subscription: PushSubscription;
22
24
  }
25
+ interface Job {
26
+ id: string;
27
+ notificationId: string;
28
+ deviceId: string;
29
+ attempts: number;
30
+ due: number;
31
+ }
23
32
  export type DeliveryOutcome = 'provider_accepted' | 'foreground_suppressed' | 'endpoint_expired' | 'retry_scheduled' | 'read_cancelled' | 'missing_target' | 'no_device';
24
33
  interface Delivery {
25
34
  userId: string;
@@ -31,9 +40,16 @@ interface Delivery {
31
40
  statusCode?: number;
32
41
  }
33
42
  type Input = Omit<Notification, 'id' | 'createdAt' | 'readAt'>;
43
+ export type Admission = Notification | {
44
+ id: string;
45
+ duplicate: true;
46
+ } | {
47
+ id: string;
48
+ dropped: 'deleted' | 'suppressed';
49
+ };
34
50
  /** The receipt fingerprint of a typed notification, as stored with it. */
35
51
  export declare const fingerprint: (value: Input) => string;
36
- /** Single-writer local service. Mutations commit a fsynced atomic snapshot before acknowledgement. */
52
+ export declare const producerKey: (userId: string, source: string) => string;
37
53
  export declare class Store {
38
54
  private readonly file;
39
55
  private state;
@@ -42,7 +58,14 @@ export declare class Store {
42
58
  private readonly observers;
43
59
  /** Neutral committed observation; never enrolls a device or changes read state. */
44
60
  subscribe(committed: () => void, signal: AbortSignal): () => void;
45
- constructor(file: string);
61
+ /** (user, source) pairs trusted with mission ownership, as `producerKey`; their typed task events are push-only. */
62
+ private readonly lifecycleProducers;
63
+ constructor(file: string, options?: {
64
+ lifecycleProducers?: ReadonlyArray<{
65
+ userId: string;
66
+ source: string;
67
+ }>;
68
+ });
46
69
  close(): void;
47
70
  private commit;
48
71
  ensureDevice(userId: string, subscription: PushSubscription): {
@@ -57,6 +80,34 @@ export declare class Store {
57
80
  revision: number;
58
81
  nextCursor: string | null | undefined;
59
82
  };
83
+ /**
84
+ * One conversation's unread evidence as of an observed revision, oldest first.
85
+ * Only records created at or before `revision` and still unread now are returned:
86
+ * the store keeps no prior read state, so a record read since `revision` is absent
87
+ * and the caller's count for that revision then stays unmet (incomplete), never
88
+ * silently satisfied. `after` resumes after a returned record; a cursor whose record
89
+ * was deleted, or that this scope and revision could not have returned, is stale (409)
90
+ * rather than restarting or skipping.
91
+ */
92
+ unreadEvidence(userId: string, targets: ChatTarget[], revision: number, after: string | undefined, limit: number): {
93
+ revision: number;
94
+ currentRevision: number;
95
+ items: {
96
+ id: string;
97
+ userId: string;
98
+ source: string;
99
+ eventId: string;
100
+ target: NotificationTarget;
101
+ anchor?: NotificationAnchor;
102
+ createdAt: string;
103
+ createdRevision?: number;
104
+ readAt: string | null;
105
+ taskId?: string | null;
106
+ /** Born read: delivered as push, never unread in the app. */
107
+ pushOnly?: true;
108
+ }[];
109
+ nextCursor: string | null;
110
+ };
60
111
  summary(userId: string): {
61
112
  unread: number;
62
113
  revision: number;
@@ -75,10 +126,61 @@ export declare class Store {
75
126
  deleteNotifications(userId: string, ids: string[]): number;
76
127
  /** A producer may retire only its own notifications for the canonical conversation. */
77
128
  deleteTarget(userId: string, source: string, value: unknown): number;
78
- produce(value: Input): Notification | {
79
- id: string;
80
- duplicate: true;
129
+ /** `pushOnly` false is an explicit inbox delivery; omitted is a legacy producer that did not state one. */
130
+ produce(input: Omit<Input, 'pushOnly'> & {
131
+ pushOnly?: boolean;
132
+ }): Admission;
133
+ /** Rows of a tombstoned mission: its task association or targets, or any proven room of it. */
134
+ private tombstoned;
135
+ /** Own mission room messages while the user turned them off. External contacts never match: only proven rooms do. */
136
+ private suppressed;
137
+ private storedPreference;
138
+ preference(userId: string): {
139
+ userId: string;
140
+ ownMissionRoomMessages: boolean;
141
+ missionRoomsReady: boolean;
81
142
  };
143
+ /** Notification preference only: OFF marks existing own-room rows read and retires their pushes; conversations are untouched. */
144
+ setPreference(userId: string, ownMissionRoomMessages: boolean): {
145
+ userId: string;
146
+ ownMissionRoomMessages: boolean;
147
+ missionRoomsReady: boolean;
148
+ };
149
+ /**
150
+ * The ownership producer registers its existing mission rooms in numbered runs. A run starts (`complete: false`)
151
+ * and completes under the same generation; an older generation is stale and a completed run cannot reopen.
152
+ * OFF can be chosen only after a run completed, so no existing own room is unassociated while OFF.
153
+ */
154
+ missionBootstrap(userId: string, input: {
155
+ generation: number;
156
+ complete: boolean;
157
+ }): {
158
+ generation: number;
159
+ complete: boolean;
160
+ };
161
+ /** Proven rooms of one mission. Revisions only advance, rooms are only added, and a tombstone wins. */
162
+ associateMission(userId: string, source: string, value: {
163
+ taskId: string;
164
+ revision: number;
165
+ contactIds: string[];
166
+ }): {
167
+ contactIds: string[];
168
+ revision: number;
169
+ };
170
+ /** One durable revision: tombstone (merging newly proven rooms), then remove every matching row and its pushes from any source. */
171
+ deleteMission(userId: string, source: string, value: {
172
+ taskId: string;
173
+ contactIds: string[];
174
+ }): {
175
+ removed: number;
176
+ };
177
+ /** Immediately before a provider call: the job, its row and its device as currently persisted, or nothing if no longer deliverable. */
178
+ dispatchable(jobId: string): {
179
+ job: Job;
180
+ notification: Notification | undefined;
181
+ device: Device | undefined;
182
+ cancelled: boolean;
183
+ } | undefined;
82
184
  due(now: number): {
83
185
  device: Device | undefined;
84
186
  notification: Notification | undefined;
package/dist/store.js CHANGED
@@ -3,6 +3,7 @@ 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
5
  import { HttpError, target, anchor, conversationTarget, subscription } from './validation.js';
6
+ import { MAX_MISSION_ROOMS, associationInput, contactKey, deletionInput, validMissions, validPreferences } from './mission.js';
6
7
  /** The receipt fingerprint of a typed notification, as stored with it. */
7
8
  export const fingerprint = (value) => createHash('sha256').update(JSON.stringify([
8
9
  'domain-target-v1', value.userId, value.source, value.eventId, value.title, value.body,
@@ -19,12 +20,18 @@ const string = (value) => { if (typeof value !== 'string' || !value)
19
20
  throw new Error(); return value; };
20
21
  const count = (value) => { if (!Number.isSafeInteger(value) || value < 0)
21
22
  throw new Error(); return value; };
22
- /** Accepts only this release's persisted format: every record typed, consistent and closed. */
23
+ /** Accepts version 3 state, or strict version 2 state upgraded explicitly; every record typed, consistent and closed. */
23
24
  function validState(value) {
24
25
  try {
25
- const state = record(value, ['version', 'revision', 'devices', 'notifications', 'jobs', 'receipts', 'deliveries']);
26
- if (state.version !== 2)
26
+ const version = record(value, ['version', 'revision', 'devices', 'notifications', 'jobs', 'receipts', 'deliveries', 'missions', 'preferences']).version;
27
+ const state = record(value, version === 3 ? ['version', 'revision', 'devices', 'notifications', 'jobs', 'receipts', 'deliveries', 'missions', 'preferences']
28
+ : ['version', 'revision', 'devices', 'notifications', 'jobs', 'receipts', 'deliveries']);
29
+ if (version !== 2 && version !== 3)
27
30
  throw new Error();
31
+ if (version === 3) {
32
+ validMissions(state.missions);
33
+ validPreferences(state.preferences);
34
+ }
28
35
  count(state.revision);
29
36
  for (const key of ['devices', 'notifications', 'jobs', 'receipts', 'deliveries'])
30
37
  if (!Array.isArray(state[key]))
@@ -38,7 +45,9 @@ function validState(value) {
38
45
  devices.set(d.id, string(d.userId));
39
46
  }
40
47
  for (const item of state.receipts) {
41
- const r = record(item, ['userId', 'source', 'eventId', 'id', 'fingerprint']);
48
+ const r = record(item, version === 3 ? ['userId', 'source', 'eventId', 'id', 'fingerprint', 'pushOnly'] : ['userId', 'source', 'eventId', 'id', 'fingerprint']);
49
+ if (r.pushOnly !== undefined && r.pushOnly !== true)
50
+ throw new Error();
42
51
  const key = JSON.stringify([string(r.userId), string(r.source), string(r.eventId)]);
43
52
  string(r.id);
44
53
  if (receipts.has(key) || !/^[a-f0-9]{64}$/.test(string(r.fingerprint)))
@@ -46,7 +55,9 @@ function validState(value) {
46
55
  receipts.set(key, r);
47
56
  }
48
57
  for (const item of state.notifications) {
49
- const n = record(item, ['id', 'userId', 'source', 'eventId', 'title', 'body', 'target', 'anchor', 'createdAt', 'createdRevision', 'readAt', 'taskId']);
58
+ const n = record(item, ['id', 'userId', 'source', 'eventId', 'title', 'body', 'target', 'anchor', 'createdAt', 'createdRevision', 'readAt', 'taskId', ...(version === 3 ? ['pushOnly'] : [])]);
59
+ if (n.pushOnly !== undefined && (n.pushOnly !== true || n.readAt === null))
60
+ throw new Error();
50
61
  for (const key of ['id', 'userId', 'source', 'eventId', 'title', 'body', 'createdAt'])
51
62
  string(n[key]);
52
63
  const destination = target(n.target);
@@ -63,7 +74,7 @@ function validState(value) {
63
74
  throw new Error();
64
75
  // Each notification's own receipt carries its typed fingerprint.
65
76
  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))
77
+ if (!own || own.id !== n.id || own.fingerprint !== fingerprint(n) || own.pushOnly !== n.pushOnly || notifications.has(n.id))
67
78
  throw new Error();
68
79
  notifications.set(n.id, n.userId);
69
80
  }
@@ -89,13 +100,103 @@ function validState(value) {
89
100
  if (!outcomes.includes(d.outcome))
90
101
  throw new Error();
91
102
  }
92
- return state;
103
+ return version === 3 ? state : upgrade(state);
93
104
  }
94
105
  catch {
95
- throw new Error('unsupported notification state: this release reads only its own version 2 state file');
106
+ throw new Error('unsupported notification state: this release reads only version 2 or 3 state files');
96
107
  }
97
108
  }
109
+ /** Version 2 → 3. Stored task lifecycle rows become push-only and read: they never count as unread again, while
110
+ * a valid pending push for a live task still goes out. A `task-deleted` row proves its task deleted: the upgrade
111
+ * tombstones that task and removes every row it owns (lifecycle, task-associated agent and room rows) with their
112
+ * pushes, so neither stored nor late events of a deleted task stay or return. Receipts stay for dedupe. */
113
+ function upgrade(v2) {
114
+ const missions = [];
115
+ for (const n of v2.notifications) {
116
+ if (n.target.kind !== 'task-deleted')
117
+ continue;
118
+ const taskId = n.target.taskId;
119
+ if (!missions.some(m => m.userId === n.userId && m.source === n.source && m.taskId === taskId))
120
+ missions.push({ userId: n.userId, source: n.source, taskId, revision: 0, contactIds: [], tombstone: { at: n.createdAt } });
121
+ }
122
+ const removed = new Set(v2.notifications.filter(n => missions.some(m => m.userId === n.userId && owns(m, n))).map(n => n.id));
123
+ const lifecycle = new Set(v2.notifications.filter(n => !removed.has(n.id) && n.target.kind === 'task').map(n => n.id));
124
+ const notifications = v2.notifications.filter(n => !removed.has(n.id))
125
+ .map(n => lifecycle.has(n.id) ? { ...n, readAt: n.readAt ?? n.createdAt, pushOnly: true } : n);
126
+ const receipts = v2.receipts.map(r => lifecycle.has(r.id) ? { ...r, pushOnly: true } : r);
127
+ return { ...v2, version: 3, notifications, receipts, jobs: v2.jobs.filter(j => !removed.has(j.notificationId)), missions, preferences: [] };
128
+ }
129
+ /**
130
+ * The trusted mission producer's typed task events (state transitions, Progress, To review) are push-only by contract.
131
+ * Proof: the row's own (user, producer source) pair is trusted with mission ownership, the target is a task, the event ID
132
+ * is scoped to that same task (`task:<taskId>:…`), and any explicit task association names the same task.
133
+ */
134
+ const TASK_EVENT = /^task:([0-9a-z]{9}[0-9a-f]{8}):/;
135
+ export const producerKey = (userId, source) => JSON.stringify([userId, source]);
136
+ function lifecycleEvent(trusted, n) {
137
+ return trusted.has(producerKey(n.userId, n.source)) && n.target.kind === 'task' && TASK_EVENT.exec(n.eventId)?.[1] === n.target.taskId
138
+ && (n.taskId === undefined || n.taskId === n.target.taskId);
139
+ }
140
+ /**
141
+ * Durable repair on open. An earlier producer could still send those events without a delivery policy after the v2
142
+ * upgrade, so they became inbox rows. Such a row (proven by the predicate on its stored target) becomes push-only: an
143
+ * unread one is read at its creation time, and a read one keeps its read time. Its own receipt follows, so a later
144
+ * push-only retry matches the stored policy and returns the same row. A pending push stays eligible, as for every
145
+ * push-only row; a push already cancelled by a read was removed then and is not recreated. A receipt whose row is gone
146
+ * carries no target, so it is never repaired here; see the retry path in `produce`. Idempotent.
147
+ */
148
+ /** Receipt mutations are bound to the exact (user, source, event ID, id) tuple, never to an id alone. */
149
+ const receiptKey = (k) => JSON.stringify([k.userId, k.source, k.eventId, k.id]);
150
+ const sameReceipt = (r, k) => receiptKey(r) === receiptKey(k);
151
+ function lifecycleRepair(state, trusted) {
152
+ const repaired = state.notifications.filter(n => !n.pushOnly && lifecycleEvent(trusted, n));
153
+ const rows = new Set(repaired.map(n => n.id)), keys = new Set(repaired.map(receiptKey));
154
+ const receipts = new Set(state.receipts.filter(r => !r.pushOnly && keys.has(receiptKey(r))));
155
+ return { rows, receipts };
156
+ }
157
+ function applyLifecycleRepair(draft, trusted) {
158
+ const { rows, receipts } = lifecycleRepair(draft, trusted);
159
+ draft.notifications = draft.notifications.map(n => rows.has(n.id) ? { ...n, readAt: n.readAt ?? n.createdAt, pushOnly: true } : n);
160
+ draft.receipts = draft.receipts.map(r => receipts.has(r) ? { ...r, pushOnly: true } : r);
161
+ }
98
162
  /** Single-writer local service. Mutations commit a fsynced atomic snapshot before acknowledgement. */
163
+ /** Reading cancels a pending push, except for push-only rows, which are born read for exactly that delivery. */
164
+ const cancelled = (n) => !!n?.readAt && !n.pushOnly;
165
+ /** Does this row belong to the mission: its explicit task association, task/room target, or one of its proven rooms. */
166
+ /** A mission's owner is a non-empty account and producer, as persisted validation requires. */
167
+ function owner(userId, source, value) {
168
+ if (typeof userId !== 'string' || !userId || typeof source !== 'string' || !source)
169
+ throw new HttpError(400, 'invalid mission owner');
170
+ return value;
171
+ }
172
+ function owns(m, n) {
173
+ const t = n.target;
174
+ if (n.taskId === m.taskId)
175
+ return true;
176
+ if ((t.kind === 'task' || t.kind === 'task-deleted' || t.kind === 'room') && t.taskId === m.taskId)
177
+ return true;
178
+ return t.kind === 'contact' && m.contactIds.includes(contactKey(t.contactId));
179
+ }
180
+ /** A message from a live, proven own mission room. */
181
+ function ownRoom(state, n) {
182
+ const t = n.target;
183
+ if (t.kind === 'room')
184
+ return state.missions.some(m => m.userId === n.userId && !m.tombstone && m.taskId === t.taskId);
185
+ if (t.kind !== 'contact')
186
+ return false;
187
+ const id = contactKey(t.contactId);
188
+ return state.missions.some(m => m.userId === n.userId && !m.tombstone && m.contactIds.includes(id));
189
+ }
190
+ /** With own-room messages off, existing unread rows from proven own rooms become read and lose their pending pushes. */
191
+ function quiet(s, userId) {
192
+ if (s.preferences.find(p => p.userId === userId)?.ownMissionRoomMessages !== false)
193
+ return;
194
+ const at = new Date().toISOString();
195
+ for (const n of s.notifications)
196
+ if (n.userId === userId && !n.readAt && ownRoom(s, n))
197
+ n.readAt = at;
198
+ s.jobs = s.jobs.filter(j => !cancelled(s.notifications.find(n => n.id === j.notificationId)));
199
+ }
99
200
  export class Store {
100
201
  file;
101
202
  state;
@@ -111,8 +212,11 @@ export class Store {
111
212
  }
112
213
  return dispose;
113
214
  }
114
- constructor(file) {
215
+ /** (user, source) pairs trusted with mission ownership, as `producerKey`; their typed task events are push-only. */
216
+ lifecycleProducers;
217
+ constructor(file, options = {}) {
115
218
  this.file = file;
219
+ this.lifecycleProducers = new Set((options.lifecycleProducers ?? []).map(p => producerKey(p.userId, p.source)));
116
220
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
117
221
  const lock = file + '.lock';
118
222
  let lockFd;
@@ -131,8 +235,15 @@ export class Store {
131
235
  }
132
236
  try {
133
237
  // 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: [] };
238
+ // Only a missing file starts empty; any existing contents, including null or other scalars, must validate.
239
+ const exists = existsSync(file), stored = exists ? JSON.parse(readFileSync(file, 'utf8')) : undefined;
240
+ this.state = exists ? validState(stored)
241
+ : { version: 3, revision: 0, devices: [], notifications: [], jobs: [], receipts: [], deliveries: [], missions: [], preferences: [] };
242
+ // Persist an upgrade at once under a strictly newer revision, so every client sees the changed unread state.
243
+ // The lifecycle repair is committed the same way, once; a repaired state has nothing left to repair.
244
+ const repair = lifecycleRepair(this.state, this.lifecycleProducers);
245
+ if (exists && (stored.version === 2 || repair.rows.size || repair.receipts.size))
246
+ this.commit(draft => applyLifecycleRepair(draft, this.lifecycleProducers));
136
247
  }
137
248
  catch (error) {
138
249
  this.close();
@@ -211,15 +322,56 @@ export class Store {
211
322
  return { items, unread: all.filter(n => !n.readAt).length, revision: this.state.revision,
212
323
  nextCursor: index > limit ? items.at(-1)?.id : null };
213
324
  }
325
+ /**
326
+ * One conversation's unread evidence as of an observed revision, oldest first.
327
+ * Only records created at or before `revision` and still unread now are returned:
328
+ * the store keeps no prior read state, so a record read since `revision` is absent
329
+ * and the caller's count for that revision then stays unmet (incomplete), never
330
+ * silently satisfied. `after` resumes after a returned record; a cursor whose record
331
+ * was deleted, or that this scope and revision could not have returned, is stale (409)
332
+ * rather than restarting or skipping.
333
+ */
334
+ unreadEvidence(userId, targets, revision, after, limit) {
335
+ if (!Number.isSafeInteger(revision) || revision < 0 || revision > this.state.revision)
336
+ throw new HttpError(400, 'invalid observed revision');
337
+ const keys = new Set(targets.map(chatKey));
338
+ const inScope = (n) => { const other = chatTarget(n.target); return !!other && keys.has(chatKey(other)); };
339
+ const all = this.state.notifications;
340
+ let start = 0;
341
+ if (after !== undefined) {
342
+ // The cursor must name a record this same scope and revision could have returned;
343
+ // having been read since keeps its position.
344
+ const index = all.findIndex(n => n.id === after);
345
+ const cursor = all[index];
346
+ if (!cursor || cursor.userId !== userId || (cursor.createdRevision ?? 0) > revision || !inScope(cursor))
347
+ throw new HttpError(409, 'stale evidence cursor');
348
+ start = index + 1;
349
+ }
350
+ // Collect one record past the page to know whether another page exists.
351
+ const items = [];
352
+ for (let index = start; index < all.length && items.length <= limit; index++) {
353
+ const n = all[index];
354
+ if (n.userId === userId && !n.readAt && (n.createdRevision ?? 0) <= revision && inScope(n))
355
+ items.push(n);
356
+ }
357
+ const more = items.length > limit;
358
+ if (more)
359
+ items.pop();
360
+ // Evidence carries identity, conversation, link and provenance; never the text.
361
+ return { revision, currentRevision: this.state.revision, items: items.map(({ title: _title, body: _body, ...rest }) => structuredClone(rest)),
362
+ nextCursor: more ? items.at(-1).id : null };
363
+ }
214
364
  summary(userId) {
215
365
  const unread = this.state.notifications.filter(n => n.userId === userId && !n.readAt);
216
366
  const groups = new Map();
217
367
  const sections = { chats: 0, tasks: 0 };
218
368
  for (const n of unread) {
219
369
  const destination = n.target;
220
- if (destination.kind === 'task' || destination.kind === 'task-deleted' || destination.kind === 'room' || typeof n.taskId === 'string')
221
- sections.tasks++;
222
- else if (destination.kind === 'contact' || n.taskId === null)
370
+ // Mission-associated rows (task and room targets, or any row carrying a taskId string,
371
+ // including agent rows) count under chats with contacts; `tasks` stays 0 for wire
372
+ // compatibility. Agent rows without a taskId field keep their prior grouping: no section.
373
+ if (destination.kind === 'task' || destination.kind === 'task-deleted' || destination.kind === 'room' || typeof n.taskId === 'string'
374
+ || destination.kind === 'contact' || n.taskId === null)
223
375
  sections.chats++;
224
376
  const conversation = chatTarget(destination);
225
377
  if (!conversation)
@@ -248,7 +400,7 @@ export class Store {
248
400
  for (const n of s.notifications)
249
401
  if (n.userId === userId && chosen.has(n.id))
250
402
  n.readAt ??= at;
251
- s.jobs = s.jobs.filter(j => !s.notifications.find(n => n.id === j.notificationId)?.readAt);
403
+ s.jobs = s.jobs.filter(j => !cancelled(s.notifications.find(n => n.id === j.notificationId)));
252
404
  });
253
405
  }
254
406
  /** Delete only this user's confirmed stale entries and their pending deliveries. */
@@ -272,7 +424,10 @@ export class Store {
272
424
  })()).map(n => n.id);
273
425
  return this.deleteNotifications(userId, ids);
274
426
  }
275
- produce(value) {
427
+ /** `pushOnly` false is an explicit inbox delivery; omitted is a legacy producer that did not state one. */
428
+ produce(input) {
429
+ const { pushOnly: explicit, ...rest } = input;
430
+ let value = { ...rest, ...(explicit ? { pushOnly: true } : {}) };
276
431
  const destination = target(value.target);
277
432
  if ('url' in value)
278
433
  throw new HttpError(400, 'unexpected field');
@@ -281,31 +436,164 @@ export class Store {
281
436
  throw new HttpError(400, 'invalid task identity');
282
437
  if ((destination.kind === 'task' || destination.kind === 'task-deleted' || destination.kind === 'room') && value.taskId !== undefined && value.taskId !== destination.taskId)
283
438
  throw new HttpError(400, 'conflicting task identity');
439
+ if (explicit !== undefined && typeof explicit !== 'boolean')
440
+ throw new HttpError(400, 'invalid delivery');
441
+ // A trusted typed task event is always push-only: without a stated policy it is admitted as if it had stated it, and
442
+ // an explicit inbox request is refused (the open-time repair would otherwise rewrite that row).
443
+ const lifecycle = lifecycleEvent(this.lifecycleProducers, value);
444
+ if (lifecycle && explicit === false)
445
+ throw new HttpError(400, 'task events from the mission producer are push-only');
446
+ const mode = lifecycle ? true : explicit;
447
+ if (mode)
448
+ value = { ...value, pushOnly: true };
284
449
  const receipt = this.state.receipts.find(r => r.userId === value.userId && r.source === value.source && r.eventId === value.eventId);
450
+ // An admitted event keeps its delivery policy: a retry may omit it (legacy producers) but never change it.
451
+ const policy = (r) => mode !== undefined && mode !== (r.pushOnly === true)
452
+ ? (() => { throw new HttpError(409, 'event id reused'); })() : undefined;
285
453
  if (receipt && receipt.fingerprint !== fingerprint(value))
286
454
  throw new HttpError(409, 'event id reused');
287
455
  const previous = this.state.notifications.find(n => n.userId === value.userId && n.source === value.source && n.eventId === value.eventId);
288
456
  if (previous) {
289
457
  if (fingerprint(previous) !== fingerprint(value))
290
458
  throw new HttpError(409, 'event id reused');
459
+ policy(previous);
291
460
  return previous;
292
461
  }
293
- if (receipt)
462
+ if (receipt) {
463
+ // An orphan receipt from an earlier producer's inbox admission: this authenticated typed task event has the same
464
+ // fingerprint (target, task and content), which proves the receipt was that event. Record its push-only policy.
465
+ if (lifecycle && !receipt.pushOnly) {
466
+ this.commit(s => { s.receipts = s.receipts.map(r => sameReceipt(r, receipt) ? { ...r, pushOnly: true } : r); });
467
+ return { id: receipt.id, duplicate: true };
468
+ }
469
+ policy(receipt);
294
470
  return { id: receipt.id, duplicate: true };
471
+ }
295
472
  // Explicit capacity failure instead of silently evicting unread history or dedupe records.
296
473
  if (this.state.receipts.filter(r => r.userId === value.userId).length >= 100_000 || this.state.notifications.filter(n => n.userId === value.userId).length >= 10_000 || this.state.jobs.length >= 20_000)
297
474
  throw new HttpError(503, 'notification capacity exceeded');
298
- const n = { ...value, id: randomUUID(), createdAt: new Date().toISOString(), createdRevision: this.state.revision + 1, readAt: null };
475
+ const n = { ...value, id: randomUUID(), createdAt: new Date().toISOString(), createdRevision: this.state.revision + 1,
476
+ readAt: value.pushOnly ? new Date().toISOString() : null };
477
+ // A deleted mission or a suppressed own room keeps only the dedupe receipt: no row, no push, no unread.
478
+ const dropped = this.tombstoned(n) ? 'deleted' : this.suppressed(n) ? 'suppressed' : undefined;
299
479
  this.commit(s => {
480
+ s.receipts.push({ userId: n.userId, source: n.source, eventId: n.eventId, id: n.id, fingerprint: fingerprint(n), ...(n.pushOnly ? { pushOnly: true } : {}) });
481
+ if (dropped)
482
+ return;
300
483
  s.notifications.push(n);
301
- s.receipts.push({ userId: n.userId, source: n.source, eventId: n.eventId, id: n.id, fingerprint: fingerprint(n) });
302
484
  if (!s.devices.some(d => d.userId === n.userId))
303
485
  this.recordDelivery(s, { userId: n.userId, notificationId: n.id, at: n.createdAt, outcome: 'no_device', attempt: 0 });
304
486
  for (const d of s.devices)
305
487
  if (d.userId === n.userId)
306
488
  s.jobs.push({ id: randomUUID(), notificationId: n.id, deviceId: d.id, attempts: 0, due: Date.now() });
307
489
  });
308
- return n;
490
+ return dropped ? { id: n.id, dropped } : n;
491
+ }
492
+ /** Rows of a tombstoned mission: its task association or targets, or any proven room of it. */
493
+ tombstoned(n, missions = this.state.missions) {
494
+ return missions.some(m => m.userId === n.userId && m.tombstone && owns(m, n));
495
+ }
496
+ /** Own mission room messages while the user turned them off. External contacts never match: only proven rooms do. */
497
+ suppressed(n, state = this.state) {
498
+ if (state.preferences.find(p => p.userId === n.userId)?.ownMissionRoomMessages !== false)
499
+ return false;
500
+ return ownRoom(state, n);
501
+ }
502
+ storedPreference(userId) {
503
+ return structuredClone(this.state.preferences.find(p => p.userId === userId) ?? { userId, ownMissionRoomMessages: true, bootstrap: null });
504
+ }
505
+ preference(userId) {
506
+ const p = this.storedPreference(userId);
507
+ return { userId, ownMissionRoomMessages: p.ownMissionRoomMessages, missionRoomsReady: !!p.bootstrap?.complete };
508
+ }
509
+ /** Notification preference only: OFF marks existing own-room rows read and retires their pushes; conversations are untouched. */
510
+ setPreference(userId, ownMissionRoomMessages) {
511
+ const current = this.storedPreference(userId);
512
+ if (!ownMissionRoomMessages && !current.bootstrap?.complete)
513
+ throw new HttpError(409, 'mission_rooms_registering');
514
+ if (current.ownMissionRoomMessages === ownMissionRoomMessages)
515
+ return this.preference(userId);
516
+ this.commit(s => {
517
+ s.preferences = [...s.preferences.filter(p => p.userId !== userId), { ...current, ownMissionRoomMessages }];
518
+ if (!ownMissionRoomMessages)
519
+ quiet(s, userId);
520
+ });
521
+ return this.preference(userId);
522
+ }
523
+ /**
524
+ * The ownership producer registers its existing mission rooms in numbered runs. A run starts (`complete: false`)
525
+ * and completes under the same generation; an older generation is stale and a completed run cannot reopen.
526
+ * OFF can be chosen only after a run completed, so no existing own room is unassociated while OFF.
527
+ */
528
+ missionBootstrap(userId, input) {
529
+ const current = this.storedPreference(userId), run = current.bootstrap;
530
+ if (run && input.generation < run.generation)
531
+ throw new HttpError(409, 'stale bootstrap generation');
532
+ if (run && input.generation === run.generation) {
533
+ if (run.complete === input.complete)
534
+ return run;
535
+ if (run.complete)
536
+ throw new HttpError(409, 'bootstrap generation already complete');
537
+ }
538
+ const bootstrap = { generation: input.generation, complete: input.complete };
539
+ this.commit(s => { s.preferences = [...s.preferences.filter(p => p.userId !== userId), { ...current, bootstrap }]; });
540
+ return bootstrap;
541
+ }
542
+ /** Proven rooms of one mission. Revisions only advance, rooms are only added, and a tombstone wins. */
543
+ associateMission(userId, source, value) {
544
+ // Write boundary: the store admits its own input with the API's parser (400 on invalid, room CIDs canonicalized), so whatever
545
+ // it commits satisfies the strict reload validation regardless of the caller.
546
+ const input = associationInput(owner(userId, source, value));
547
+ const existing = this.state.missions.find(m => m.userId === userId && m.source === source && m.taskId === input.taskId);
548
+ if (existing?.tombstone)
549
+ throw new HttpError(409, 'mission deleted');
550
+ if (existing && existing.revision > input.revision)
551
+ throw new HttpError(409, 'stale association');
552
+ const contactIds = [...new Set([...(existing?.contactIds ?? []), ...input.contactIds])];
553
+ if (existing && existing.revision === input.revision && contactIds.length === existing.contactIds.length)
554
+ return { contactIds, revision: existing.revision };
555
+ if (existing && existing.revision === input.revision)
556
+ throw new HttpError(409, 'conflicting association');
557
+ if (contactIds.length > MAX_MISSION_ROOMS)
558
+ throw new HttpError(503, 'mission room capacity exceeded');
559
+ if (!existing && this.state.missions.filter(m => m.userId === userId).length >= 10_000)
560
+ throw new HttpError(503, 'mission capacity exceeded');
561
+ this.commit(s => {
562
+ s.missions = [...s.missions.filter(m => m !== existing && !(m.userId === userId && m.source === source && m.taskId === input.taskId)),
563
+ { userId, source, taskId: input.taskId, revision: input.revision, contactIds }];
564
+ quiet(s, userId);
565
+ });
566
+ return { contactIds, revision: input.revision };
567
+ }
568
+ /** One durable revision: tombstone (merging newly proven rooms), then remove every matching row and its pushes from any source. */
569
+ deleteMission(userId, source, value) {
570
+ const input = deletionInput(owner(userId, source, value));
571
+ const existing = this.state.missions.find(m => m.userId === userId && m.source === source && m.taskId === input.taskId);
572
+ const contactIds = [...new Set([...(existing?.contactIds ?? []), ...input.contactIds])];
573
+ // Refuse rather than truncate: a dropped room could never be cleaned up.
574
+ if (contactIds.length > MAX_MISSION_ROOMS)
575
+ throw new HttpError(503, 'mission room capacity exceeded');
576
+ const mission = { userId, source, taskId: input.taskId, revision: existing?.revision ?? 0, contactIds, tombstone: existing?.tombstone ?? { at: new Date().toISOString() } };
577
+ const removed = new Set(this.state.notifications.filter(n => n.userId === userId && owns(mission, n)).map(n => n.id));
578
+ if (existing?.tombstone && existing.contactIds.length === contactIds.length && !removed.size)
579
+ return { removed: 0 };
580
+ if (!existing && this.state.missions.filter(m => m.userId === userId).length >= 10_000)
581
+ throw new HttpError(503, 'mission capacity exceeded');
582
+ this.commit(s => {
583
+ s.missions = [...s.missions.filter(m => !(m.userId === userId && m.source === source && m.taskId === input.taskId)), mission];
584
+ s.notifications = s.notifications.filter(n => !removed.has(n.id));
585
+ s.jobs = s.jobs.filter(j => !removed.has(j.notificationId));
586
+ });
587
+ return { removed: removed.size };
588
+ }
589
+ /** Immediately before a provider call: the job, its row and its device as currently persisted, or nothing if no longer deliverable. */
590
+ dispatchable(jobId) {
591
+ const j = this.state.jobs.find(j => j.id === jobId);
592
+ if (!j)
593
+ return undefined;
594
+ const notification = this.state.notifications.find(n => n.id === j.notificationId), device = this.state.devices.find(d => d.id === j.deviceId);
595
+ return { job: structuredClone(j), notification: notification && structuredClone(notification), device: device && structuredClone(device),
596
+ cancelled: cancelled(notification) };
309
597
  }
310
598
  due(now) {
311
599
  return this.state.jobs.filter(j => j.due <= now).slice(0, 32).map(j => ({ ...j,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/notifications",
3
- "version": "0.3.1-nightly.7",
3
+ "version": "1.0.1",
4
4
  "description": "Authenticated web push delivery and shared notification inbox",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",