@ours.network/notifications 0.3.1-nightly.2 → 0.3.1-nightly.4
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 +36 -3
- package/dist/chat-target.d.ts +6 -7
- package/dist/chat-target.js +27 -8
- package/dist/server.js +8 -6
- package/dist/store.d.ts +25 -6
- package/dist/store.js +35 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,11 +56,11 @@ Responses are never cached. User and producer credentials have separate scopes.
|
|
|
56
56
|
| GET `/api/v1/devices/status?id=...` | User | `{ "active": true }` |
|
|
57
57
|
| GET `/api/v1/inbox?limit=50&before=...` | User | Items, shared unread count, revision, next cursor |
|
|
58
58
|
| GET `/api/v1/summary` | User | Shared unread count, revision, per-chat source/identity/count, and `sections: {chats, tasks}` |
|
|
59
|
-
| POST `/api/v1/read-target` | User | `{ "url": "/fleet/chats
|
|
59
|
+
| POST `/api/v1/read-target` | User | `{ "url": "/fleet/chats/agents/agent", "revision": OBSERVED_REVISION }`, reads only this chat up to the revision captured before its history load |
|
|
60
60
|
| POST `/api/v1/read` | User | `{ "ids": ["notification-id"] }`, idempotent shared read |
|
|
61
61
|
| POST `/api/v1/delete` | User | `{ "ids": ["notification-id"] }` → `{ "removed": COUNT }`; persistently removes only this user’s entries and queued deliveries after the client has confirmed target absence |
|
|
62
|
-
| POST `/api/v1/delete-target` | Producer | `{ "url": "/fleet/chats
|
|
63
|
-
| POST `/api/v1/send` | Producer | `{ "eventId": "stable-event-id", "title": "Agent completed", "body": "Open the chat", "url": "/fleet/chats
|
|
62
|
+
| POST `/api/v1/delete-target` | Producer | `{ "url": "/fleet/chats/agents/agent" }` → `{ "removed": COUNT }`; only its credential’s user/source and exact canonical conversation, including filter/anchor variants |
|
|
63
|
+
| POST `/api/v1/send` | Producer | `{ "eventId": "stable-event-id", "title": "Agent completed", "body": "Open the chat", "url": "/fleet/chats/agents/agent" }` |
|
|
64
64
|
|
|
65
65
|
Inbox admission and queued delivery are one fsynced atomic snapshot. The service
|
|
66
66
|
deduplicates by user/source/event ID; replay must retain identical title/body/URL
|
|
@@ -140,3 +140,36 @@ limit remove frozen/disconnected clients. Only that foreground device's push
|
|
|
140
140
|
is suppressed; its inbox item remains unread until explicitly marked read.
|
|
141
141
|
Other devices still receive push. `inbox.changed` invalidates account-wide inbox
|
|
142
142
|
views after producer admission or reads. Presence is ephemeral across restart.
|
|
143
|
+
|
|
144
|
+
## Task lifecycle delivery and diagnostics
|
|
145
|
+
|
|
146
|
+
Task notifications from Fleet identify creation and every committed lifecycle change,
|
|
147
|
+
including block/unblock and deletion. Task routes remain eligible for push while a
|
|
148
|
+
registered device is foreground; ordinary chat delivery keeps its existing server
|
|
149
|
+
presence policy. The browser must display every push it has already received:
|
|
150
|
+
Safari can revoke subscriptions after silent pushes, so suppression must happen
|
|
151
|
+
before sending, never by returning from the worker's push handler.
|
|
152
|
+
|
|
153
|
+
`GET /api/v1/delivery/status` requires the owner's user credential (never a producer
|
|
154
|
+
credential). It returns active device/pending-job counts and the latest 100 safe
|
|
155
|
+
outcomes for that account: provider acceptance, foreground suppression, expired
|
|
156
|
+
endpoint, scheduled retry, cancellation or no registered device. The service retains
|
|
157
|
+
at most 1,000 outcomes per account. Records contain IDs, timestamps, attempt counts
|
|
158
|
+
and numeric provider status only; endpoints, encryption keys, notification contents
|
|
159
|
+
and provider bodies are excluded. `provider_accepted` proves provider acceptance,
|
|
160
|
+
not display or receipt on a phone.
|
|
161
|
+
|
|
162
|
+
Failed push jobs persist with exponential backoff capped at one hour until they
|
|
163
|
+
succeed, are read/deleted, or their endpoint expires (404/410). Restart preserves
|
|
164
|
+
retry work. Re-registering a subscription with changed keys gets a new device ID,
|
|
165
|
+
so a late expiry response cannot remove its replacement. Background subscription
|
|
166
|
+
renewal requires the workspace page to reopen and reconcile its subscription;
|
|
167
|
+
revoked permission requires an explicit user action in browser/device settings.
|
|
168
|
+
|
|
169
|
+
Acceptance receipts retain event identity and a canonical payload hash even after
|
|
170
|
+
an inbox item is deleted, preventing retry resurrection. Legacy inbox entries are
|
|
171
|
+
admitted as receipts on load. The existing inbox/job caps remain; a separate cap of
|
|
172
|
+
100,000 receipts per account rejects new events explicitly rather than evicting
|
|
173
|
+
dedupe evidence. Upgrades must preserve the service's state file and scoped credentials.
|
|
174
|
+
|
|
175
|
+
Conversation notification links use canonical contact (`/fleet/chats/contacts/:id`), agent (`/fleet/chats/agents/:id`), and task room (`/fleet/tasks/:taskId/room` or `/fleet/tasks/:taskId/rooms/:key`) paths. Deploy producers, this service, and the app together. Summary rows retain `source`/`chat` and include `kind` (`contact`, `agent`, or `room`) to distinguish colliding IDs. Filters and message anchors do not change target identity. Old query-based conversation URLs are intentionally unsupported; stored payload URLs are neither converted nor deleted by this change. Existing navigation-target security restrictions still apply.
|
package/dist/chat-target.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
export interface ChatTarget {
|
|
2
|
+
kind: 'contact' | 'agent' | 'room';
|
|
3
3
|
source: 'work' | 'messenger';
|
|
4
4
|
chat: string;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}): string;
|
|
5
|
+
}
|
|
6
|
+
/** Stable conversation identity, independent of list filters and message anchors. */
|
|
7
|
+
export declare function chatTarget(path: string): ChatTarget | null;
|
|
8
|
+
export declare function chatKey(value: ChatTarget): string;
|
package/dist/chat-target.js
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
|
-
|
|
1
|
+
import { target as navigationTarget } from './validation.js';
|
|
2
|
+
/** Stable conversation identity, independent of list filters and message anchors. */
|
|
2
3
|
export function chatTarget(path) {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
if (
|
|
4
|
+
try {
|
|
5
|
+
navigationTarget(path);
|
|
6
|
+
const url = new URL(path, 'https://ours.invalid');
|
|
7
|
+
if (['source', 'chat', 'task', 'detail'].some(key => url.searchParams.has(key)))
|
|
7
8
|
return null;
|
|
8
|
-
|
|
9
|
+
// Match the original path: URL normalization must not turn malformed arity into a target.
|
|
10
|
+
const parts = path.split(/[?#]/, 1)[0].split('/').slice(1).map(decodeURIComponent);
|
|
11
|
+
if (parts.some(part => !part || /[\x00-\x1f\x7f]/.test(part) || part === '.' || part === '..'))
|
|
12
|
+
return null;
|
|
13
|
+
if (parts.length === 4 && parts[0] === 'fleet' && parts[1] === 'chats') {
|
|
14
|
+
if (parts[2] === 'contacts')
|
|
15
|
+
return { kind: 'contact', source: 'messenger', chat: parts[3] };
|
|
16
|
+
if (parts[2] === 'agents')
|
|
17
|
+
return { kind: 'agent', source: 'work', chat: parts[3] };
|
|
18
|
+
}
|
|
19
|
+
if (parts[0] === 'fleet' && parts[1] === 'tasks' && /^[A-Za-z0-9_-]{1,120}$/.test(parts[2] ?? '')) {
|
|
20
|
+
if (parts.length === 4 && parts[3] === 'room')
|
|
21
|
+
return { kind: 'room', source: 'work', chat: `room-${parts[2]}` };
|
|
22
|
+
if (parts.length === 5 && parts[3] === 'rooms' && /^[A-Za-z][A-Za-z0-9_-]*$/.test(parts[4])
|
|
23
|
+
&& !['constructor', 'prototype', '__proto__'].includes(parts[4])) {
|
|
24
|
+
return { kind: 'room', source: 'work', chat: `room-${parts[2]}:${parts[4]}` };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
9
27
|
}
|
|
28
|
+
catch { /* Unsupported persisted input is unknown, never a missing target. */ }
|
|
10
29
|
return null;
|
|
11
30
|
}
|
|
12
31
|
export function chatKey(value) {
|
|
13
|
-
const chat = value.
|
|
14
|
-
return JSON.stringify([value.source, chat]);
|
|
32
|
+
const chat = value.kind === 'contact' && /^[a-f0-9]{64}$/i.test(value.chat) ? value.chat.toUpperCase() : value.chat;
|
|
33
|
+
return JSON.stringify([value.kind, value.source, chat]);
|
|
15
34
|
}
|
package/dist/server.js
CHANGED
|
@@ -61,6 +61,8 @@ export function createNotificationService(config, send) {
|
|
|
61
61
|
let result;
|
|
62
62
|
if (req.method === 'GET' && url.pathname === '/api/v1/push/config')
|
|
63
63
|
result = { publicKey: config.vapid.publicKey };
|
|
64
|
+
else if (req.method === 'GET' && url.pathname === '/api/v1/delivery/status')
|
|
65
|
+
result = store.deliveryStatus(user);
|
|
64
66
|
else if (req.method === 'GET' && url.pathname === '/api/v1/summary')
|
|
65
67
|
result = store.summary(user);
|
|
66
68
|
else if (req.method === 'GET' && url.pathname === '/api/v1/inbox') {
|
|
@@ -128,21 +130,21 @@ export function createNotificationService(config, send) {
|
|
|
128
130
|
const drain = () => draining ??= (async () => {
|
|
129
131
|
for (const j of store.due(Date.now())) {
|
|
130
132
|
if (!j.device || !j.notification || j.notification.readAt) {
|
|
131
|
-
store.finish(j.id, 'sent', Date.now());
|
|
133
|
+
store.finish(j.id, 'sent', Date.now(), j.notification?.readAt ? 'read_cancelled' : 'missing_target');
|
|
132
134
|
continue;
|
|
133
135
|
}
|
|
134
|
-
if (presence.online(j.device.userId, j.device.id)) {
|
|
135
|
-
store.finish(j.id, 'sent', Date.now());
|
|
136
|
+
if (!/^\/fleet\/tasks(?:\/[^/]+)?\/?$/.test(new URL(j.notification.url, 'https://ours.invalid').pathname) && presence.online(j.device.userId, j.device.id)) {
|
|
137
|
+
store.finish(j.id, 'sent', Date.now(), 'foreground_suppressed');
|
|
136
138
|
continue;
|
|
137
139
|
}
|
|
138
140
|
try {
|
|
139
|
-
await deliver(j.device.subscription, JSON.stringify({ v: 1, kind: 'notification', title: j.notification.title,
|
|
141
|
+
const result = await deliver(j.device.subscription, JSON.stringify({ v: 1, kind: 'notification', title: j.notification.title,
|
|
140
142
|
body: j.notification.body, url: j.notification.url, notification_id: j.notification.id, tag: j.notification.id }));
|
|
141
|
-
store.finish(j.id, 'sent', Date.now());
|
|
143
|
+
store.finish(j.id, 'sent', Date.now(), 'provider_accepted', result?.statusCode);
|
|
142
144
|
}
|
|
143
145
|
catch (error) {
|
|
144
146
|
const status = error.statusCode;
|
|
145
|
-
store.finish(j.id, status === 404 || status === 410 ? 'gone' : 'retry', Date.now());
|
|
147
|
+
store.finish(j.id, status === 404 || status === 410 ? 'gone' : 'retry', Date.now(), status === 404 || status === 410 ? 'endpoint_expired' : 'retry_scheduled', status);
|
|
146
148
|
}
|
|
147
149
|
}
|
|
148
150
|
})().finally(() => { draining = undefined; });
|
package/dist/store.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ChatTarget } from './chat-target.js';
|
|
1
2
|
import type { PushSubscription } from 'web-push';
|
|
2
3
|
export interface Notification {
|
|
3
4
|
id: string;
|
|
@@ -17,6 +18,16 @@ interface Device {
|
|
|
17
18
|
userId: string;
|
|
18
19
|
subscription: PushSubscription;
|
|
19
20
|
}
|
|
21
|
+
export type DeliveryOutcome = 'provider_accepted' | 'foreground_suppressed' | 'endpoint_expired' | 'retry_scheduled' | 'read_cancelled' | 'missing_target' | 'no_device';
|
|
22
|
+
interface Delivery {
|
|
23
|
+
userId: string;
|
|
24
|
+
notificationId: string;
|
|
25
|
+
deviceId?: string;
|
|
26
|
+
at: string;
|
|
27
|
+
outcome: DeliveryOutcome;
|
|
28
|
+
attempt: number;
|
|
29
|
+
statusCode?: number;
|
|
30
|
+
}
|
|
20
31
|
/** Single-writer local service. Mutations commit a fsynced atomic snapshot before acknowledgement. */
|
|
21
32
|
export declare class Store {
|
|
22
33
|
private readonly file;
|
|
@@ -41,11 +52,9 @@ export declare class Store {
|
|
|
41
52
|
summary(userId: string): {
|
|
42
53
|
unread: number;
|
|
43
54
|
revision: number;
|
|
44
|
-
chats: {
|
|
45
|
-
source: "work" | "messenger";
|
|
46
|
-
chat: string;
|
|
55
|
+
chats: (ChatTarget & {
|
|
47
56
|
count: number;
|
|
48
|
-
}[];
|
|
57
|
+
})[];
|
|
49
58
|
sections: {
|
|
50
59
|
chats: number;
|
|
51
60
|
tasks: number;
|
|
@@ -57,7 +66,10 @@ export declare class Store {
|
|
|
57
66
|
deleteNotifications(userId: string, ids: string[]): number;
|
|
58
67
|
/** A producer may retire only its own notifications for the canonical conversation. */
|
|
59
68
|
deleteTarget(userId: string, source: string, url: string): number;
|
|
60
|
-
produce(value: Omit<Notification, 'id' | 'createdAt' | 'readAt'>): Notification
|
|
69
|
+
produce(value: Omit<Notification, 'id' | 'createdAt' | 'readAt'>): Notification | {
|
|
70
|
+
id: string;
|
|
71
|
+
duplicate: true;
|
|
72
|
+
};
|
|
61
73
|
due(now: number): {
|
|
62
74
|
device: Device | undefined;
|
|
63
75
|
notification: Notification | undefined;
|
|
@@ -67,6 +79,13 @@ export declare class Store {
|
|
|
67
79
|
attempts: number;
|
|
68
80
|
due: number;
|
|
69
81
|
}[];
|
|
70
|
-
|
|
82
|
+
/** Bounded, account-scoped operational evidence; never endpoints, keys, payloads or provider bodies. */
|
|
83
|
+
deliveryStatus(userId: string): {
|
|
84
|
+
devices: number;
|
|
85
|
+
pending: number;
|
|
86
|
+
recent: Delivery[];
|
|
87
|
+
};
|
|
88
|
+
private recordDelivery;
|
|
89
|
+
finish(id: string, status: 'sent' | 'gone' | 'retry', now: number, outcome?: DeliveryOutcome, statusCode?: number): void;
|
|
71
90
|
}
|
|
72
91
|
export {};
|
package/dist/store.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { chatTarget, chatKey } from './chat-target.js';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
3
|
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname } from 'node:path';
|
|
5
5
|
import { HttpError } from './validation.js';
|
|
6
|
+
const fingerprint = (value) => createHash('sha256')
|
|
7
|
+
.update(JSON.stringify([value.userId, value.source, value.eventId, value.title, value.body, value.url, value.taskId === undefined ? [] : [value.taskId]])).digest('hex');
|
|
6
8
|
/** Single-writer local service. Mutations commit a fsynced atomic snapshot before acknowledgement. */
|
|
7
9
|
export class Store {
|
|
8
10
|
file;
|
|
@@ -29,6 +31,8 @@ export class Store {
|
|
|
29
31
|
}
|
|
30
32
|
try {
|
|
31
33
|
this.state = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { version: 1, revision: 0, devices: [], notifications: [], jobs: [] };
|
|
34
|
+
this.state.receipts ??= this.state.notifications.map(n => ({ userId: n.userId, source: n.source, eventId: n.eventId, id: n.id, fingerprint: fingerprint(n) }));
|
|
35
|
+
this.state.deliveries ??= [];
|
|
32
36
|
if (this.state.version !== 1 || !Array.isArray(this.state.notifications) || !Array.isArray(this.state.devices) || !Array.isArray(this.state.jobs))
|
|
33
37
|
throw new Error('invalid notification state');
|
|
34
38
|
}
|
|
@@ -107,10 +111,10 @@ export class Store {
|
|
|
107
111
|
const sections = { chats: 0, tasks: 0 };
|
|
108
112
|
for (const n of unread) {
|
|
109
113
|
const url = new URL(n.url, 'https://ours.invalid'), targetForSection = chatTarget(n.url);
|
|
110
|
-
if (/^\/fleet\/tasks
|
|
114
|
+
if (/^\/fleet\/tasks(?:\/[^/]+)?\/?$/.test(url.pathname))
|
|
111
115
|
sections.tasks++;
|
|
112
116
|
else if (targetForSection) {
|
|
113
|
-
if (typeof n.taskId === 'string' || targetForSection.
|
|
117
|
+
if (typeof n.taskId === 'string' || targetForSection.kind === 'room')
|
|
114
118
|
sections.tasks++;
|
|
115
119
|
else if (targetForSection.source === 'messenger')
|
|
116
120
|
sections.chats++;
|
|
@@ -176,18 +180,26 @@ export class Store {
|
|
|
176
180
|
return this.deleteNotifications(userId, ids);
|
|
177
181
|
}
|
|
178
182
|
produce(value) {
|
|
183
|
+
const receipt = this.state.receipts.find(r => r.userId === value.userId && r.source === value.source && r.eventId === value.eventId);
|
|
184
|
+
if (receipt && receipt.fingerprint !== fingerprint(value))
|
|
185
|
+
throw new HttpError(409, 'event id reused');
|
|
179
186
|
const previous = this.state.notifications.find(n => n.userId === value.userId && n.source === value.source && n.eventId === value.eventId);
|
|
180
187
|
if (previous) {
|
|
181
188
|
if (previous.title !== value.title || previous.body !== value.body || previous.url !== value.url || previous.taskId !== value.taskId)
|
|
182
189
|
throw new HttpError(409, 'event id reused');
|
|
183
190
|
return previous;
|
|
184
191
|
}
|
|
192
|
+
if (receipt)
|
|
193
|
+
return { id: receipt.id, duplicate: true };
|
|
185
194
|
// Explicit capacity failure instead of silently evicting unread history or dedupe records.
|
|
186
|
-
if (this.state.notifications.filter(n => n.userId === value.userId).length >= 10_000 || this.state.jobs.length >= 20_000)
|
|
195
|
+
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)
|
|
187
196
|
throw new HttpError(503, 'notification capacity exceeded');
|
|
188
197
|
const n = { ...value, id: randomUUID(), createdAt: new Date().toISOString(), createdRevision: this.state.revision + 1, readAt: null };
|
|
189
198
|
this.commit(s => {
|
|
190
199
|
s.notifications.push(n);
|
|
200
|
+
s.receipts.push({ userId: n.userId, source: n.source, eventId: n.eventId, id: n.id, fingerprint: fingerprint(n) });
|
|
201
|
+
if (!s.devices.some(d => d.userId === n.userId))
|
|
202
|
+
this.recordDelivery(s, { userId: n.userId, notificationId: n.id, at: n.createdAt, outcome: 'no_device', attempt: 0 });
|
|
191
203
|
for (const d of s.devices)
|
|
192
204
|
if (d.userId === n.userId)
|
|
193
205
|
s.jobs.push({ id: randomUUID(), notificationId: n.id, deviceId: d.id, attempts: 0, due: Date.now() });
|
|
@@ -198,20 +210,35 @@ export class Store {
|
|
|
198
210
|
return this.state.jobs.filter(j => j.due <= now).slice(0, 32).map(j => ({ ...j,
|
|
199
211
|
device: this.state.devices.find(d => d.id === j.deviceId), notification: this.state.notifications.find(n => n.id === j.notificationId) }));
|
|
200
212
|
}
|
|
201
|
-
|
|
213
|
+
/** Bounded, account-scoped operational evidence; never endpoints, keys, payloads or provider bodies. */
|
|
214
|
+
deliveryStatus(userId) {
|
|
215
|
+
return { devices: this.state.devices.filter(d => d.userId === userId).length,
|
|
216
|
+
pending: this.state.jobs.filter(j => this.state.notifications.some(n => n.id === j.notificationId && n.userId === userId)).length,
|
|
217
|
+
recent: this.state.deliveries.filter(d => d.userId === userId).slice(-100).reverse() };
|
|
218
|
+
}
|
|
219
|
+
recordDelivery(state, value) {
|
|
220
|
+
const sameUser = state.deliveries.filter(d => d.userId === value.userId).slice(-999);
|
|
221
|
+
state.deliveries = [...state.deliveries.filter(d => d.userId !== value.userId), ...sameUser, value];
|
|
222
|
+
}
|
|
223
|
+
finish(id, status, now, outcome = status === 'sent' ? 'provider_accepted' : status === 'gone' ? 'endpoint_expired' : 'retry_scheduled', statusCode) {
|
|
202
224
|
this.commit(s => {
|
|
203
225
|
const j = s.jobs.find(j => j.id === id);
|
|
204
226
|
if (!j)
|
|
205
227
|
return;
|
|
228
|
+
const n = s.notifications.find(n => n.id === j.notificationId);
|
|
229
|
+
if (n)
|
|
230
|
+
this.recordDelivery(s, { userId: n.userId, notificationId: n.id, deviceId: j.deviceId,
|
|
231
|
+
at: new Date(now).toISOString(), outcome, attempt: j.attempts + 1,
|
|
232
|
+
...(Number.isInteger(statusCode) && statusCode >= 100 && statusCode <= 599 ? { statusCode } : {}) });
|
|
206
233
|
if (status === 'gone') {
|
|
207
234
|
s.devices = s.devices.filter(d => d.id !== j.deviceId);
|
|
208
235
|
s.jobs = s.jobs.filter(v => v.deviceId !== j.deviceId);
|
|
209
236
|
}
|
|
210
|
-
else if (status === 'sent'
|
|
237
|
+
else if (status === 'sent')
|
|
211
238
|
s.jobs = s.jobs.filter(v => v.id !== id);
|
|
212
239
|
else {
|
|
213
|
-
j.attempts
|
|
214
|
-
j.due = now + Math.min(60_000 * 2 ** j.attempts, 3_600_000);
|
|
240
|
+
j.attempts = Math.min(j.attempts + 1, 1024);
|
|
241
|
+
j.due = now + Math.min(60_000 * 2 ** Math.min(j.attempts, 6), 3_600_000);
|
|
215
242
|
}
|
|
216
243
|
});
|
|
217
244
|
}
|
package/package.json
CHANGED