agentic-notify 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +111 -2
- package/dist/chunk-3N77SYRA.js +168 -0
- package/dist/chunk-6QCYA3X7.js +401 -0
- package/dist/chunk-FJPEUDTG.js +64 -0
- package/dist/chunk-KJKM56FT.js +31 -0
- package/dist/chunk-POSHKDNY.js +86 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +226 -0
- package/dist/dispatch-LBnkLOQg.d.ts +77 -0
- package/dist/gdpr-CUq5MUwo.d.ts +9 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +44 -0
- package/dist/mcp-bin.d.ts +1 -0
- package/dist/mcp-bin.js +18 -0
- package/dist/mcp.d.ts +11 -0
- package/dist/mcp.js +12 -0
- package/dist/sdk.d.ts +110 -0
- package/dist/sdk.js +8 -0
- package/dist/testing-B5slvYDV.d.ts +27 -0
- package/dist/testing.d.ts +5 -0
- package/dist/testing.js +10 -0
- package/dist/types-C842YAMe.d.ts +217 -0
- package/dist/types-DONKNIOo.d.ts +20 -0
- package/dist/worker.d.ts +65 -0
- package/dist/worker.js +178 -0
- package/package.json +88 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pooria Arab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,112 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agentic-notify
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/pooriaarab/agentic-notify/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Notifications for humans and agents: in-app inbox, email and webhook delivery, with a CLI, API, MCP server and SDK.
|
|
7
|
+
|
|
8
|
+
**Status: 0.2.0.** Breaking for 0.1.0 code and databases, see the CHANGELOG upgrade steps. Works today: the notification model, preferences and mute, digest, a store port from agentic-identity with its memory and D1 adapters, signed webhook and email delivery through a dispatcher, an HTTP API in a Worker, an SDK, a CLI, MCP tools, per-recipient export and erase, and a retention sweep. Not done: a real email provider adapter, webhook endpoint management, and a web UI.
|
|
9
|
+
|
|
10
|
+
## Live demo
|
|
11
|
+
|
|
12
|
+
A demo Worker runs at https://agentic-notify-demo.hello-fd1.workers.dev. `GET /health` answers 200. The `/api/v1` routes need a bearer token, so without one they answer 401. The demo keeps its data in the EU and sweeps notifications after 7 days.
|
|
13
|
+
|
|
14
|
+
Redeploy your own copy with `wrangler.demo.jsonc` (create the D1 database with `--location weur`, put its id in the file, set the `NOTIFY_TOKENS` secret):
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx wrangler secret put NOTIFY_TOKENS --config wrangler.demo.jsonc
|
|
18
|
+
WRANGLER_SEND_METRICS=false npm run deploy:demo
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install agentic-notify
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
Library, with the memory store:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createNotifier, createMemoryStore } from "agentic-notify";
|
|
33
|
+
|
|
34
|
+
const notifier = createNotifier({ store: createMemoryStore() });
|
|
35
|
+
await notifier.send({
|
|
36
|
+
recipient: { kind: "agent", id: "helper" },
|
|
37
|
+
type: "approval_request",
|
|
38
|
+
resource: { kind: "doc", id: "d1" },
|
|
39
|
+
actor: { kind: "human", id: "ana" },
|
|
40
|
+
body: "Please approve the edit",
|
|
41
|
+
});
|
|
42
|
+
const { items } = await notifier.list({ kind: "agent", id: "helper" });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Worker (D1 binding `DB`, secret `NOTIFY_TOKENS`):
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
# Create the database first: npx wrangler d1 create agentic-notify --location weur
|
|
49
|
+
# NOTIFY_TOKENS is JSON: { "<token of 32+ bytes>": { "kind": "human", "id": "ana", "canSend": true } }
|
|
50
|
+
npx wrangler secret put NOTIFY_TOKENS
|
|
51
|
+
npx wrangler deploy
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Without a D1 binding or `NOTIFY_TOKENS`, every `/api/v1` call answers 503. Nobody gets in by default.
|
|
55
|
+
|
|
56
|
+
CLI, SDK and MCP talk to that Worker:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
export NOTIFY_URL=https://notify.example.org NOTIFY_TOKEN=...
|
|
60
|
+
npx agentic-notify send --to human:ana --type mention --resource doc:d1 --body "Look at this"
|
|
61
|
+
npx agentic-notify list --unread
|
|
62
|
+
npx agentic-notify mcp # tools: list_notifications, read_notification, mark_read,
|
|
63
|
+
# mark_all_read, get_preferences, set_preferences, send_notification
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { createClient } from "agentic-notify/sdk";
|
|
68
|
+
const client = createClient({ baseUrl: process.env.NOTIFY_URL!, token: process.env.NOTIFY_TOKEN! });
|
|
69
|
+
console.log(await client.unreadCount());
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## HTTP API
|
|
73
|
+
|
|
74
|
+
All routes sit under `/api/v1` and need `Authorization: Bearer <token>`. `GET /health` is open.
|
|
75
|
+
|
|
76
|
+
| Route | Use |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `GET /notifications?unread=1&limit=&cursor=` | List yours, newest first |
|
|
79
|
+
| `POST /notifications` | Send. Needs the send right. The caller is the actor |
|
|
80
|
+
| `GET /notifications/:id`, `POST /notifications/:id/read` | Read one, mark read |
|
|
81
|
+
| `POST /notifications/read-all`, `GET /unread-count`, `GET /digest?since=` | Bulk read state, digest |
|
|
82
|
+
| `GET /preferences`, `PUT /preferences` | Channels per type, mute |
|
|
83
|
+
| `GET /me/export`, `DELETE /me` | GDPR export and hard erase |
|
|
84
|
+
|
|
85
|
+
## Identity
|
|
86
|
+
|
|
87
|
+
Auth is a port: `PrincipalResolver` turns a request into `{ kind: "human" | "agent", id }` or null. `createStaticTokenResolver` is a small example. To use another identity system, call `createWorker({ resolver })` with your own resolver. A caller reads and changes only its own notifications.
|
|
88
|
+
|
|
89
|
+
## Privacy
|
|
90
|
+
|
|
91
|
+
- Export and erase work per recipient. `retentionMs` plus `sweep()` (or a cron trigger on the Worker, with `NOTIFY_RETENTION_DAYS`) deletes old notifications.
|
|
92
|
+
- Erase and sweep delete in batches of 100 inside one transaction each, so a large erase does not cost one query per row.
|
|
93
|
+
- The Worker reads `NOTIFY_REGION` (`eu`, `us` or `unknown`, default `eu`). The D1 adapter refuses a database that does not report a serving region in that area. For local runs without a real D1, set `NOTIFY_REGION=unknown`.
|
|
94
|
+
- Logs hold ids, channel names and error names only. They never hold bodies or addresses.
|
|
95
|
+
- Erase removes a recipient's own notifications and preferences. Notifications that name the person only as actor stay with their recipients.
|
|
96
|
+
|
|
97
|
+
## Limits
|
|
98
|
+
|
|
99
|
+
- The default Worker serves the inbox. To fan out to webhook and email, compose `createApp` with `createDispatcher` in your own Worker.
|
|
100
|
+
- Webhook endpoints and email addresses come from resolvers you provide. The package stores none.
|
|
101
|
+
- Webhook retries run inside the request. Use a queue for heavy fan-out.
|
|
102
|
+
|
|
103
|
+
## Develop
|
|
104
|
+
|
|
105
|
+
```sh
|
|
106
|
+
npm ci
|
|
107
|
+
npm run ci:local
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createMemoryStore } from "agentic-identity";
|
|
3
|
+
|
|
4
|
+
// src/delivery/dispatch.ts
|
|
5
|
+
function createDispatcher(options) {
|
|
6
|
+
return {
|
|
7
|
+
/** Store in the inbox, then try each other channel the recipient chose. One failure never blocks the rest. */
|
|
8
|
+
async notify(input) {
|
|
9
|
+
const sent = await options.notifier.send(input);
|
|
10
|
+
const deliveries = [];
|
|
11
|
+
const notification = sent.notification;
|
|
12
|
+
if (notification) {
|
|
13
|
+
for (const channel of sent.channels) {
|
|
14
|
+
if (channel === "inbox") continue;
|
|
15
|
+
const impl = options.channels[channel];
|
|
16
|
+
if (!impl) {
|
|
17
|
+
deliveries.push({ channel, ok: false, skipped: "no_channel" });
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const outcome = await impl.deliver(notification);
|
|
22
|
+
deliveries.push(outcome === "sent" ? { channel, ok: true } : { channel, ok: false, skipped: "no_target" });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
const name = error instanceof Error ? error.name : "Error";
|
|
25
|
+
deliveries.push({ channel, ok: false, error: name });
|
|
26
|
+
options.logger?.warn("delivery failed", { id: notification.id, channel, error: name });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { notification, stored: sent.stored, muted: sent.muted, deliveries };
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// src/delivery/types.ts
|
|
36
|
+
var DeliveryError = class extends Error {
|
|
37
|
+
constructor(message, attempts, options) {
|
|
38
|
+
super(message, options);
|
|
39
|
+
this.attempts = attempts;
|
|
40
|
+
this.name = "DeliveryError";
|
|
41
|
+
}
|
|
42
|
+
attempts;
|
|
43
|
+
code = "DELIVERY_FAILED";
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/delivery/webhook.ts
|
|
47
|
+
var enc = new TextEncoder();
|
|
48
|
+
var MIN_SECRET_BYTES = 32;
|
|
49
|
+
var SIGNATURE_HEADER = "x-notify-signature";
|
|
50
|
+
var DEFAULT_TOLERANCE_SECONDS = 300;
|
|
51
|
+
function assertSecret(secret) {
|
|
52
|
+
if (typeof secret !== "string" || enc.encode(secret).length < MIN_SECRET_BYTES) {
|
|
53
|
+
throw new Error(`webhook secret must be a string of at least ${MIN_SECRET_BYTES} bytes`);
|
|
54
|
+
}
|
|
55
|
+
return secret;
|
|
56
|
+
}
|
|
57
|
+
function hex(bytes) {
|
|
58
|
+
return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
59
|
+
}
|
|
60
|
+
function fromHex(text) {
|
|
61
|
+
if (!/^(?:[0-9a-f]{2})+$/.test(text)) return null;
|
|
62
|
+
const out = new Uint8Array(text.length / 2);
|
|
63
|
+
for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(text.slice(i * 2, i * 2 + 2), 16);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function hmacKey(secret, usage) {
|
|
67
|
+
return crypto.subtle.importKey("raw", enc.encode(assertSecret(secret)), { name: "HMAC", hash: "SHA-256" }, false, [usage]);
|
|
68
|
+
}
|
|
69
|
+
async function signPayload(secret, body, timestamp) {
|
|
70
|
+
if (!Number.isInteger(timestamp) || timestamp < 0) throw new RangeError("timestamp must be a unix time in seconds");
|
|
71
|
+
const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret, "sign"), enc.encode(`${timestamp}.${body}`));
|
|
72
|
+
return `t=${timestamp},v1=${hex(sig)}`;
|
|
73
|
+
}
|
|
74
|
+
async function verifySignature(secret, body, header, options = {}) {
|
|
75
|
+
const key = await hmacKey(secret, "verify");
|
|
76
|
+
if (typeof header !== "string") return false;
|
|
77
|
+
const m = /^t=(\d{1,15}),v1=([0-9a-f]+)$/.exec(header);
|
|
78
|
+
if (!m) return false;
|
|
79
|
+
const t = Number(m[1]);
|
|
80
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
81
|
+
const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
|
|
82
|
+
if (!Number.isFinite(now) || !Number.isFinite(tolerance) || Math.abs(now - t) > tolerance) return false;
|
|
83
|
+
const sig = fromHex(m[2]);
|
|
84
|
+
if (!sig) return false;
|
|
85
|
+
return crypto.subtle.verify("HMAC", key, sig, enc.encode(`${t}.${body}`));
|
|
86
|
+
}
|
|
87
|
+
function webhookBody(n) {
|
|
88
|
+
return JSON.stringify({
|
|
89
|
+
id: n.id,
|
|
90
|
+
type: n.type,
|
|
91
|
+
recipient: n.recipient,
|
|
92
|
+
resource: n.resource,
|
|
93
|
+
actor: n.actor,
|
|
94
|
+
body: n.body,
|
|
95
|
+
createdAt: n.createdAt
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
function retryable(status) {
|
|
99
|
+
return status === 408 || status === 429 || status >= 500;
|
|
100
|
+
}
|
|
101
|
+
function createWebhookChannel(options) {
|
|
102
|
+
const doFetch = options.fetch ?? fetch;
|
|
103
|
+
const maxAttempts = options.maxAttempts ?? 4;
|
|
104
|
+
const base = options.baseDelayMs ?? 500;
|
|
105
|
+
const cap = options.maxDelayMs ?? 3e4;
|
|
106
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
107
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
108
|
+
const random = options.random ?? Math.random;
|
|
109
|
+
const now = options.now ?? (() => Date.now());
|
|
110
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) throw new RangeError("maxAttempts must be 1 or more");
|
|
111
|
+
for (const [name, value] of [["baseDelayMs", base], ["maxDelayMs", cap], ["timeoutMs", timeoutMs]]) {
|
|
112
|
+
if (!Number.isFinite(value) || value < 0) throw new RangeError(`${name} must be a finite number of 0 or more`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
async deliver(n) {
|
|
116
|
+
const target = await options.resolve(n.recipient);
|
|
117
|
+
if (!target) return "skipped";
|
|
118
|
+
assertSecret(target.secret);
|
|
119
|
+
const url = new URL(target.url);
|
|
120
|
+
if (url.protocol !== "https:" && !(options.allowInsecure && url.protocol === "http:")) {
|
|
121
|
+
throw new DeliveryError("webhook url must use https", 0);
|
|
122
|
+
}
|
|
123
|
+
const body = webhookBody(n);
|
|
124
|
+
let lastReason = "";
|
|
125
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
126
|
+
const signature = await signPayload(target.secret, body, Math.floor(now() / 1e3));
|
|
127
|
+
let retry = true;
|
|
128
|
+
try {
|
|
129
|
+
const res = await doFetch(url.toString(), {
|
|
130
|
+
method: "POST",
|
|
131
|
+
redirect: "manual",
|
|
132
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
133
|
+
headers: { "content-type": "application/json", [SIGNATURE_HEADER]: signature, "x-notify-id": n.id },
|
|
134
|
+
body
|
|
135
|
+
});
|
|
136
|
+
if (res.status >= 200 && res.status < 300) return "sent";
|
|
137
|
+
lastReason = `status ${res.status}`;
|
|
138
|
+
retry = retryable(res.status);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
lastReason = error instanceof Error ? error.name : "error";
|
|
141
|
+
}
|
|
142
|
+
if (!retry) throw new DeliveryError(`webhook failed: ${lastReason}`, attempt);
|
|
143
|
+
if (attempt < maxAttempts) {
|
|
144
|
+
const delay = Math.min(cap, base * 2 ** (attempt - 1));
|
|
145
|
+
await sleep(Math.floor(delay / 2 + random() * (delay / 2)));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
throw new DeliveryError(`webhook failed: ${lastReason}`, maxAttempts);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// src/index.ts
|
|
154
|
+
import { createD1Store, D1_SCHEMA } from "agentic-identity/cloudflare";
|
|
155
|
+
var PACKAGE_NAME = "agentic-notify";
|
|
156
|
+
|
|
157
|
+
export {
|
|
158
|
+
createDispatcher,
|
|
159
|
+
DeliveryError,
|
|
160
|
+
SIGNATURE_HEADER,
|
|
161
|
+
signPayload,
|
|
162
|
+
verifySignature,
|
|
163
|
+
createWebhookChannel,
|
|
164
|
+
PACKAGE_NAME,
|
|
165
|
+
createMemoryStore,
|
|
166
|
+
createD1Store,
|
|
167
|
+
D1_SCHEMA
|
|
168
|
+
};
|