@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 +33 -4
- package/dist/mission.d.ts +50 -0
- package/dist/mission.js +94 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.js +69 -10
- package/dist/store.d.ts +107 -5
- package/dist/store.js +308 -20
- package/package.json +1 -1
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/
|
|
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
|
|
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[];
|
package/dist/mission.js
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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,
|