@xtremax/pigeon 0.0.0-stage → 0.3.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/CHANGELOG.md +22 -0
- package/README.md +68 -2
- package/bin/pigeon.mjs +92 -0
- package/dist/cjs/browser-stub.js +10 -0
- package/dist/cjs/client.js +112 -0
- package/dist/cjs/contract.js +5 -0
- package/dist/cjs/errors.js +48 -0
- package/dist/cjs/events.js +62 -0
- package/dist/cjs/idempotency.js +14 -0
- package/dist/cjs/inbox/browser-stub.js +10 -0
- package/dist/cjs/inbox/client.js +39 -0
- package/dist/cjs/inbox/errors.js +44 -0
- package/dist/cjs/inbox/handler.js +127 -0
- package/dist/cjs/inbox/index.js +15 -0
- package/dist/cjs/inbox/origin.js +22 -0
- package/dist/cjs/inbox/project.js +84 -0
- package/dist/cjs/inbox/validate.js +59 -0
- package/dist/cjs/inbox-react/format.js +72 -0
- package/dist/cjs/inbox-react/inbox-client.js +63 -0
- package/dist/cjs/inbox-react/inbox-context.js +13 -0
- package/dist/cjs/inbox-react/inbox-full-list.js +13 -0
- package/dist/cjs/inbox-react/inbox-list.js +37 -0
- package/dist/cjs/inbox-react/inbox-panel.js +13 -0
- package/dist/cjs/inbox-react/inbox-provider.js +157 -0
- package/dist/cjs/inbox-react/inbox-state.js +88 -0
- package/dist/cjs/inbox-react/index.js +25 -0
- package/dist/cjs/inbox-react/labels.js +38 -0
- package/dist/cjs/inbox-react/notification-bell.js +32 -0
- package/dist/cjs/inbox-react/notification-item.js +17 -0
- package/dist/cjs/inbox-react/parse.js +94 -0
- package/dist/cjs/inbox-react/preference-dialog.js +49 -0
- package/dist/cjs/inbox-react/types.js +4 -0
- package/dist/cjs/inbox-react/use-inbox-polling.js +94 -0
- package/dist/cjs/index.js +14 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/retry.js +14 -0
- package/dist/esm/browser-stub.js +6 -0
- package/dist/esm/client.js +107 -0
- package/dist/esm/contract.js +2 -0
- package/dist/esm/errors.js +42 -0
- package/dist/esm/events.js +57 -0
- package/dist/esm/idempotency.js +10 -0
- package/dist/esm/inbox/browser-stub.js +7 -0
- package/dist/esm/inbox/client.js +35 -0
- package/dist/esm/inbox/errors.js +38 -0
- package/dist/esm/inbox/handler.js +124 -0
- package/dist/esm/inbox/index.js +5 -0
- package/dist/esm/inbox/origin.js +19 -0
- package/dist/esm/inbox/project.js +76 -0
- package/dist/esm/inbox/validate.js +49 -0
- package/dist/esm/inbox-react/format.js +64 -0
- package/dist/esm/inbox-react/inbox-client.js +58 -0
- package/dist/esm/inbox-react/inbox-context.js +9 -0
- package/dist/esm/inbox-react/inbox-full-list.js +10 -0
- package/dist/esm/inbox-react/inbox-list.js +34 -0
- package/dist/esm/inbox-react/inbox-panel.js +10 -0
- package/dist/esm/inbox-react/inbox-provider.js +154 -0
- package/dist/esm/inbox-react/inbox-state.js +83 -0
- package/dist/esm/inbox-react/index.js +18 -0
- package/dist/esm/inbox-react/labels.js +34 -0
- package/dist/esm/inbox-react/notification-bell.js +29 -0
- package/dist/esm/inbox-react/notification-item.js +14 -0
- package/dist/esm/inbox-react/parse.js +85 -0
- package/dist/esm/inbox-react/preference-dialog.js +46 -0
- package/dist/esm/inbox-react/types.js +1 -0
- package/dist/esm/inbox-react/use-inbox-polling.js +88 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/retry.js +9 -0
- package/dist/inbox-react/styles.css +65 -0
- package/dist/types/browser-stub.d.ts +4 -0
- package/dist/types/client.d.ts +94 -0
- package/dist/types/contract.d.ts +182 -0
- package/dist/types/errors.d.ts +29 -0
- package/dist/types/events.d.ts +26 -0
- package/dist/types/idempotency.d.ts +4 -0
- package/dist/types/inbox/browser-stub.d.ts +3 -0
- package/dist/types/inbox/client.d.ts +34 -0
- package/dist/types/inbox/errors.d.ts +13 -0
- package/dist/types/inbox/handler.d.ts +25 -0
- package/dist/types/inbox/index.d.ts +6 -0
- package/dist/types/inbox/origin.d.ts +5 -0
- package/dist/types/inbox/project.d.ts +7 -0
- package/dist/types/inbox/validate.d.ts +25 -0
- package/dist/types/inbox-react/format.d.ts +13 -0
- package/dist/types/inbox-react/inbox-client.d.ts +24 -0
- package/dist/types/inbox-react/inbox-context.d.ts +29 -0
- package/dist/types/inbox-react/inbox-full-list.d.ts +7 -0
- package/dist/types/inbox-react/inbox-list.d.ts +12 -0
- package/dist/types/inbox-react/inbox-panel.d.ts +2 -0
- package/dist/types/inbox-react/inbox-provider.d.ts +18 -0
- package/dist/types/inbox-react/inbox-state.d.ts +70 -0
- package/dist/types/inbox-react/index.d.ts +19 -0
- package/dist/types/inbox-react/labels.d.ts +40 -0
- package/dist/types/inbox-react/notification-bell.d.ts +4 -0
- package/dist/types/inbox-react/notification-item.d.ts +9 -0
- package/dist/types/inbox-react/parse.d.ts +9 -0
- package/dist/types/inbox-react/preference-dialog.d.ts +5 -0
- package/dist/types/inbox-react/types.d.ts +55 -0
- package/dist/types/inbox-react/use-inbox-polling.d.ts +33 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/retry.d.ts +13 -0
- package/package.json +34 -4
- package/skills/add-pigeon-notifications/SKILL.md +302 -0
- package/skills/add-pigeon-notifications/references/inbox-contract.instructions.md +208 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.1
|
|
4
|
+
- The package carries its agent skill, `skills/add-pigeon-notifications/`: the steps a coding agent follows to add the bell and the sending code to an app.
|
|
5
|
+
- New command `npx @xtremax/pigeon add-skill`: copies the skill into the project's `.claude/skills/`, and into `.kiro/skills/` when the project has a `.kiro` folder. Run it again after an upgrade to get the new version's skill. `--check` says what would change.
|
|
6
|
+
- No change to the library.
|
|
7
|
+
|
|
8
|
+
## 0.3.0
|
|
9
|
+
- The package is named `@xtremax/pigeon`. Earlier versions were called `@xtremax/communication` and were never published. Only the import path changes: function names, environment variables and headers are the same.
|
|
10
|
+
- New entry `@xtremax/pigeon/inbox-react`: the notification bell (`InboxProvider`, `NotificationBell`, `InboxFullList`), with its stylesheet at `@xtremax/pigeon/inbox-react/styles.css`. It replaces the files that were copied into each app. React 19, Mantine 8 and `@tabler/icons-react` are optional peer dependencies.
|
|
11
|
+
- `createInboxHandler` no longer needs `client`: without one it makes a client on the first request from `COMMUNICATION_API_KEY` and `COMMUNICATION_URL`, and a missing one answers `unavailable`.
|
|
12
|
+
- The ESM build loads in plain Node: relative imports carry their file extension.
|
|
13
|
+
- Moving from the copied files: remove `components/ui/communication-inbox/`, import from `@xtremax/pigeon/inbox-react`, and import the stylesheet once. The preference switch is now Mantine's `Switch`.
|
|
14
|
+
|
|
15
|
+
## 0.2.0
|
|
16
|
+
- New entry `@xtremax/pigeon/inbox` (server only): `createInboxClient` (summary, list, mark read, mark all read, preference) and `createInboxHandler`, the host xApp's browser-facing inbox routes in one function.
|
|
17
|
+
- `ping()` also answers `subKey`: the sub-tenant the key belongs to, whose notifications the inbox calls return.
|
|
18
|
+
- Inbox types in `/contract`: `InboxNotification`, `InboxSummary`, `InboxNotificationPage`, `InboxPreference`, `InboxHostErrorCode`.
|
|
19
|
+
- No change to existing calls.
|
|
20
|
+
|
|
21
|
+
## 0.1.0
|
|
22
|
+
- First release: `createCommunicationClient` (messages, channels, ping, recipients, preferences, message classes), `createEventHandler`, `verifyEventSignature`, `deriveIdempotencyKey`, contract types.
|
package/README.md
CHANGED
|
@@ -1,3 +1,69 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @xtremax/pigeon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Client for Pigeon, the Buildpad Communication Gateway. Send email, WhatsApp and in-app notifications through one call, verify signed delivery events, and show a person their in-app notifications with a ready-made bell.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @xtremax/pigeon
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The names in the code say `Communication` (`createCommunicationClient`, `COMMUNICATION_API_KEY`): that is the service's name on the Buildpad platform, where Pigeon is the Communication connector.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createCommunicationClient } from '@xtremax/pigeon/client';
|
|
13
|
+
const communication = createCommunicationClient(); // COMMUNICATION_API_KEY and COMMUNICATION_URL
|
|
14
|
+
|
|
15
|
+
await communication.messages.send({ templateKey: 'welcome', channel: 'email-main', recipient: { personId: 'ana@acme.com' }, variables: { name: 'Ana' } });
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **Server only.** The client holds a secret key; importing it in a browser bundle resolves to a stub that throws `server_only`.
|
|
19
|
+
- **Safe retries.** Network errors, timeouts, `429` and `5xx` are retried (3 attempts, 250 ms base, 5 s cap, 25 s total, honouring `Retry-After`). Every send carries an idempotency key, so a retry never sends twice. Client errors are never retried.
|
|
20
|
+
- **Errors you can branch on.** `CommunicationError` with `code`, `status`, `requestId`, `details`, `retryable`. The key never appears in an error.
|
|
21
|
+
- **Events.** `createEventHandler` and `verifyEventSignature` (`@xtremax/pigeon/events`) check the HMAC over the raw body, the five-minute window and secret rotation.
|
|
22
|
+
|
|
23
|
+
## In-app inbox
|
|
24
|
+
|
|
25
|
+
A person's in-app notifications are read through your app's own server, never from the browser. Two pieces: one catch-all route, and the bell.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// app/api/communication/inbox/[...path]/route.ts
|
|
29
|
+
import { createInboxHandler } from '@xtremax/pigeon/inbox';
|
|
30
|
+
|
|
31
|
+
const handler = createInboxHandler({
|
|
32
|
+
resolvePerson: async () => (await currentUser())?.email ?? null, // your own session
|
|
33
|
+
});
|
|
34
|
+
export { handler as GET, handler as POST, handler as PUT };
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
// in your header, a client component
|
|
39
|
+
import { InboxProvider, NotificationBell } from '@xtremax/pigeon/inbox-react';
|
|
40
|
+
import '@xtremax/pigeon/inbox-react/styles.css'; // once, after Mantine's styles
|
|
41
|
+
|
|
42
|
+
<InboxProvider>
|
|
43
|
+
<NotificationBell />
|
|
44
|
+
</InboxProvider>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
- **The bell needs** React 19, Mantine 8 (`@mantine/core`, `@mantine/hooks`) and `@tabler/icons-react` in your app. They are optional peers: an app that only sends needs none of them. The bell follows your Mantine theme and colour scheme.
|
|
48
|
+
- **`InboxProvider` takes** `apiBase` (default `/api/communication/inbox`), `labels` (every visible and spoken text), `locale`, `onViewAll`, `onOpenPreferences` and `openLink`. `InboxFullList` is the full list, for a page of your own.
|
|
49
|
+
- **The key and the address** (`COMMUNICATION_API_KEY`, `COMMUNICATION_URL`) are read on the first request. A missing one answers `unavailable`; pass `onError` to see why in your server log. Pass `client` to use a client of your own.
|
|
50
|
+
|
|
51
|
+
- **One function is yours:** `resolvePerson(request)` returns the signed-in person's id from your own session, or `null`. Use the same id you send to as `personId`. The handler never reads a person from the path, query or body.
|
|
52
|
+
- **Scopes:** the key needs `inbox:read` and `inbox:write`.
|
|
53
|
+
- **What the browser can get wrong is bounded:** six operations, validated input, a same-origin check on POST and PUT, `Cache-Control: no-store`, and six fixed error codes (`not_signed_in`, `not_permitted`, `not_found`, `invalid_input`, `rate_limited`, `unavailable`).
|
|
54
|
+
- **Direct calls:** `createInboxClient()` has `summary`, `list`, `markRead`, `markAllRead`, `getPreference` and `setPreference`, each taking the person id first.
|
|
55
|
+
- **What a key sees:** what its own sub-tenant (or the tenant level, for a key outside any) sent the person, and the tenant's announcements. Another app's notifications are not part of it, and the on/off preference is this app's own. `ping()` returns the key's `subKey`.
|
|
56
|
+
|
|
57
|
+
## For AI coding agents
|
|
58
|
+
|
|
59
|
+
The package carries a skill that tells a coding agent (Claude Code, Kiro and others that read `SKILL.md`) how to add the bell and the sending code to an app, step by step, with tests and a troubleshooting table.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npx @xtremax/pigeon add-skill
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
It copies `add-pigeon-notifications` into the project's `.claude/skills/`, and into `.kiro/skills/` when the project has a `.kiro` folder (`--kiro` forces it, `--dir` names another project folder, `--check` changes nothing and says what would change). Then ask the agent to "add a notification bell", or run `/add-pigeon-notifications`. Run the command again after upgrading the package: the skill's files are replaced with the new version's.
|
|
66
|
+
|
|
67
|
+
An agent that has not been given the skill can read it in place: `node_modules/@xtremax/pigeon/skills/add-pigeon-notifications/SKILL.md`.
|
|
68
|
+
|
|
69
|
+
Entry points: `/client`, `/inbox`, `/events` and `/contract` (types only) for the server; `/inbox-react` and `/inbox-react/styles.css` for the browser. Node 20 or later. Below 1.0 a breaking change raises the minor version.
|
package/bin/pigeon.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The package's command line. One job: put the agent skills that ship with the package where a coding agent finds them.
|
|
3
|
+
//
|
|
4
|
+
// npx @xtremax/pigeon add-skill copy the skills into this project
|
|
5
|
+
// npx @xtremax/pigeon add-skill --check say what would change, and change nothing
|
|
6
|
+
// npx @xtremax/pigeon add-skill --dir app another project folder
|
|
7
|
+
//
|
|
8
|
+
// The skills go to .claude/skills/ always, and to .kiro/skills/ when the project has a .kiro folder (or with --kiro).
|
|
9
|
+
// Run it again after upgrading the package: it replaces the skill's files with the new version's.
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
const skillsRoot = join(packageRoot, 'skills');
|
|
16
|
+
const { name, version } = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
|
|
17
|
+
|
|
18
|
+
const USAGE = `${name} ${version}
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
npx ${name} add-skill [--dir <project folder>] [--kiro] [--check]
|
|
22
|
+
|
|
23
|
+
add-skill Copy the agent skills of this package into the project:
|
|
24
|
+
.claude/skills/<skill>/, and .kiro/skills/<skill>/ when the project has a .kiro folder.
|
|
25
|
+
--dir The project folder. Default: the current folder.
|
|
26
|
+
--kiro Also write .kiro/skills/ when the project has no .kiro folder yet.
|
|
27
|
+
--check Say what would change and change nothing. Exits 1 when something would.
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
function fail(message) {
|
|
31
|
+
process.stderr.write(`${message}\n\n${USAGE}`);
|
|
32
|
+
process.exit(2);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every file under a folder, as paths relative to it. */
|
|
36
|
+
function filesOf(dir, base = dir) {
|
|
37
|
+
return readdirSync(dir).flatMap((entry) => {
|
|
38
|
+
const path = join(dir, entry);
|
|
39
|
+
return statSync(path).isDirectory() ? filesOf(path, base) : [relative(base, path)];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function addSkill(args) {
|
|
44
|
+
let dir = process.cwd();
|
|
45
|
+
let kiro = false;
|
|
46
|
+
let check = false;
|
|
47
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
48
|
+
const arg = args[i];
|
|
49
|
+
if (arg === '--dir') {
|
|
50
|
+
const value = args[(i += 1)];
|
|
51
|
+
if (!value) fail('--dir needs a folder.');
|
|
52
|
+
dir = resolve(value);
|
|
53
|
+
} else if (arg === '--kiro') kiro = true;
|
|
54
|
+
else if (arg === '--check') check = true;
|
|
55
|
+
else fail(`Unknown option: ${arg}`);
|
|
56
|
+
}
|
|
57
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) fail(`Not a folder: ${dir}`);
|
|
58
|
+
// A project, not any folder: writing skills into the wrong place helps nobody.
|
|
59
|
+
if (!existsSync(join(dir, 'package.json'))) fail(`No package.json in ${dir}. Run this in the project's folder, or pass --dir.`);
|
|
60
|
+
|
|
61
|
+
const skills = readdirSync(skillsRoot).filter((entry) => existsSync(join(skillsRoot, entry, 'SKILL.md')));
|
|
62
|
+
const homes = ['.claude/skills', ...(kiro || existsSync(join(dir, '.kiro')) ? ['.kiro/skills'] : [])];
|
|
63
|
+
const counts = { added: 0, updated: 0, same: 0 };
|
|
64
|
+
|
|
65
|
+
for (const home of homes) {
|
|
66
|
+
for (const skill of skills) {
|
|
67
|
+
for (const file of filesOf(join(skillsRoot, skill))) {
|
|
68
|
+
const content = readFileSync(join(skillsRoot, skill, file));
|
|
69
|
+
const target = join(dir, home, skill, file);
|
|
70
|
+
const state = !existsSync(target) ? 'added' : readFileSync(target).equals(content) ? 'same' : 'updated';
|
|
71
|
+
counts[state] += 1;
|
|
72
|
+
if (state === 'same') continue;
|
|
73
|
+
process.stdout.write(`${check ? `would be ${state}` : state}: ${join(home, skill, file)}\n`);
|
|
74
|
+
if (check) continue;
|
|
75
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
76
|
+
writeFileSync(target, content);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const changed = counts.added + counts.updated;
|
|
82
|
+
if (changed === 0) process.stdout.write(`Up to date: ${skills.join(', ')} (${name} ${version}) in ${homes.join(' and ')}.\n`);
|
|
83
|
+
else if (check) process.stdout.write(`${changed} file(s) would change. Run without --check to write them.\n`);
|
|
84
|
+
else process.stdout.write(`\nDone: ${skills.join(', ')} (${name} ${version}) in ${homes.join(' and ')}.\nAsk your coding agent to "add a notification bell", or run /${skills[0]}.\n`);
|
|
85
|
+
process.exit(check && changed > 0 ? 1 : 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
89
|
+
if (command === 'add-skill') addSkill(rest);
|
|
90
|
+
else if (command === undefined || command === 'help' || command === '--help' || command === '-h') process.stdout.write(USAGE);
|
|
91
|
+
else if (command === '--version' || command === '-v') process.stdout.write(`${version}\n`);
|
|
92
|
+
else fail(`Unknown command: ${command}`);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CommunicationError = void 0;
|
|
4
|
+
exports.createCommunicationClient = createCommunicationClient;
|
|
5
|
+
const errors_1 = require("./errors");
|
|
6
|
+
Object.defineProperty(exports, "CommunicationError", { enumerable: true, get: function () { return errors_1.CommunicationError; } });
|
|
7
|
+
/** Selected by the `browser` export condition so a secret key can never be bundled into client code. */
|
|
8
|
+
function createCommunicationClient() {
|
|
9
|
+
throw new errors_1.CommunicationError({ code: 'server_only', message: 'The Communication client must run on the server.' });
|
|
10
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CommunicationError = exports.TRANSPORT = void 0;
|
|
4
|
+
exports.createCommunicationClient = createCommunicationClient;
|
|
5
|
+
const errors_1 = require("./errors");
|
|
6
|
+
const idempotency_1 = require("./idempotency");
|
|
7
|
+
const retry_1 = require("./retry");
|
|
8
|
+
/** Where a client keeps its transport, so `@xtremax/pigeon/inbox` can reuse the key handling, retries and errors. */
|
|
9
|
+
exports.TRANSPORT = Symbol.for('@xtremax/pigeon.transport');
|
|
10
|
+
const sleepDefault = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
function createCommunicationClient(options = {}) {
|
|
12
|
+
if (typeof window !== 'undefined' && typeof globalThis.document !== 'undefined') {
|
|
13
|
+
throw new errors_1.CommunicationError({ code: 'server_only', message: 'The Communication client holds a secret key and must run on the server.' });
|
|
14
|
+
}
|
|
15
|
+
const env = typeof process !== 'undefined' ? process.env : {};
|
|
16
|
+
const apiKey = options.apiKey ?? env.COMMUNICATION_API_KEY;
|
|
17
|
+
const baseUrl = (options.baseUrl ?? env.COMMUNICATION_URL ?? '').replace(/\/$/, '');
|
|
18
|
+
if (!apiKey || !baseUrl)
|
|
19
|
+
throw new errors_1.CommunicationError({ code: 'not_configured', message: 'Set COMMUNICATION_API_KEY and COMMUNICATION_URL (the connector provides both).' });
|
|
20
|
+
const policy = { ...retry_1.DEFAULT_RETRY, ...options.retry };
|
|
21
|
+
const doFetch = options.fetch ?? fetch;
|
|
22
|
+
const sleep = options.sleep ?? sleepDefault;
|
|
23
|
+
async function request(method, path, body, opts = {}) {
|
|
24
|
+
const started = Date.now();
|
|
25
|
+
const max = opts.retry === false ? 1 : policy.maxAttempts;
|
|
26
|
+
const totalMs = Math.min(policy.totalTimeoutMs, opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : policy.totalTimeoutMs);
|
|
27
|
+
let last = null;
|
|
28
|
+
for (let attempt = 1; attempt <= max; attempt += 1) {
|
|
29
|
+
const ctrl = new AbortController();
|
|
30
|
+
const timer = setTimeout(() => ctrl.abort(), Math.min(policy.perAttemptTimeoutMs, Math.max(1, totalMs - (Date.now() - started))));
|
|
31
|
+
try {
|
|
32
|
+
const res = await doFetch(`${baseUrl}/api/external/v1${path}`, {
|
|
33
|
+
method,
|
|
34
|
+
headers: { ...(opts.headers ?? {}), Authorization: `Bearer ${apiKey}`, Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
35
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
36
|
+
signal: ctrl.signal,
|
|
37
|
+
redirect: 'error',
|
|
38
|
+
});
|
|
39
|
+
const text = await res.text();
|
|
40
|
+
let parsed = null;
|
|
41
|
+
try {
|
|
42
|
+
parsed = text ? JSON.parse(text) : null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
if (res.ok)
|
|
46
|
+
throw new errors_1.CommunicationError({ code: 'invalid_response', message: 'The service answered with something that is not JSON', status: res.status, attempts: attempt });
|
|
47
|
+
}
|
|
48
|
+
if (res.ok)
|
|
49
|
+
return { data: parsed.data, headers: res.headers };
|
|
50
|
+
last = (0, errors_1.fromResponse)(res.status, parsed, res.headers, attempt);
|
|
51
|
+
if (!(0, retry_1.isRetryableStatus)(res.status))
|
|
52
|
+
throw last;
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
if (e instanceof errors_1.CommunicationError) {
|
|
56
|
+
if (!e.retryable || e.code === 'invalid_response')
|
|
57
|
+
throw e;
|
|
58
|
+
last = e;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const aborted = e.name === 'AbortError';
|
|
62
|
+
last = new errors_1.CommunicationError({ code: aborted ? 'timeout' : 'network_error', message: aborted ? 'The request timed out' : 'The service could not be reached', retryable: true, attempts: attempt });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
}
|
|
68
|
+
const wait = (0, retry_1.backoffMs)(attempt, policy, last?.retryAfterSeconds ?? null, options.random);
|
|
69
|
+
if (attempt >= max || Date.now() - started + wait >= totalMs)
|
|
70
|
+
break;
|
|
71
|
+
await sleep(wait);
|
|
72
|
+
}
|
|
73
|
+
throw new errors_1.CommunicationError({ code: last?.code ?? 'unknown_error', apiCode: last?.apiCode, message: last?.message ?? 'The request failed', status: last?.status, requestId: last?.requestId, details: last?.details, retryable: true, retryAfterSeconds: last?.retryAfterSeconds, attempts: Math.min(max, policy.maxAttempts) });
|
|
74
|
+
}
|
|
75
|
+
const put = (path, body) => request('PUT', path, body).then((r) => r.data);
|
|
76
|
+
const enc = encodeURIComponent;
|
|
77
|
+
return {
|
|
78
|
+
messages: {
|
|
79
|
+
/** Sends are retried safely because every request carries an idempotency key (yours, or a random one). */
|
|
80
|
+
async send(req) {
|
|
81
|
+
const { data } = await request('POST', '/messages', { ...req, idempotencyKey: req.idempotencyKey ?? (0, idempotency_1.newIdempotencyKey)() });
|
|
82
|
+
return data;
|
|
83
|
+
},
|
|
84
|
+
get: (id) => request('GET', `/messages/${enc(id)}`).then((r) => r.data),
|
|
85
|
+
},
|
|
86
|
+
channels: { list: () => request('GET', '/channels').then((r) => r.data) },
|
|
87
|
+
ping: () => request('GET', '/ping').then((r) => r.data),
|
|
88
|
+
recipients: {
|
|
89
|
+
put: (personId, body) => put(`/recipients/${enc(personId)}`, body),
|
|
90
|
+
get: (personId) => request('GET', `/recipients/${enc(personId)}`).then((r) => r.data),
|
|
91
|
+
async delete(personId) {
|
|
92
|
+
await request('DELETE', `/recipients/${enc(personId)}`);
|
|
93
|
+
},
|
|
94
|
+
putAddress: (personId, body) => put(`/recipients/${enc(personId)}/addresses`, body),
|
|
95
|
+
async deleteAddress(personId, addressId) {
|
|
96
|
+
await request('DELETE', `/recipients/${enc(personId)}/addresses/${enc(addressId)}`);
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
preferences: {
|
|
100
|
+
record: (body) => request('POST', '/preferences', body).then((r) => r.data),
|
|
101
|
+
},
|
|
102
|
+
messageClasses: {
|
|
103
|
+
put: (templateKey, body) => put(`/message-classes/${enc(templateKey)}`, body),
|
|
104
|
+
list: () => request('GET', '/message-classes').then((r) => r.data),
|
|
105
|
+
},
|
|
106
|
+
/** For logs: never prints the key. */
|
|
107
|
+
describe: () => (0, errors_1.redact)(`CommunicationClient(${baseUrl})`),
|
|
108
|
+
[exports.TRANSPORT]: request,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
var errors_2 = require("./errors");
|
|
112
|
+
Object.defineProperty(exports, "CommunicationError", { enumerable: true, get: function () { return errors_2.CommunicationError; } });
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CommunicationError = exports.redact = void 0;
|
|
4
|
+
exports.fromResponse = fromResponse;
|
|
5
|
+
const KEY_PATTERN = /cgk_[A-Za-z0-9]{8}_[A-Za-z0-9_-]{43}/g;
|
|
6
|
+
/** Removes anything shaped like a key from text that may be logged. */
|
|
7
|
+
const redact = (text) => text.replace(KEY_PATTERN, 'cgk_[redacted]').replace(/Bearer\s+\S+/gi, 'Bearer [redacted]');
|
|
8
|
+
exports.redact = redact;
|
|
9
|
+
class CommunicationError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
apiCode;
|
|
12
|
+
status;
|
|
13
|
+
requestId;
|
|
14
|
+
details;
|
|
15
|
+
retryable;
|
|
16
|
+
retryAfterSeconds;
|
|
17
|
+
attempts;
|
|
18
|
+
constructor(init) {
|
|
19
|
+
super((0, exports.redact)(init.message));
|
|
20
|
+
this.name = 'CommunicationError';
|
|
21
|
+
this.code = init.code;
|
|
22
|
+
this.apiCode = init.apiCode ?? null;
|
|
23
|
+
this.status = init.status ?? null;
|
|
24
|
+
this.requestId = init.requestId ?? null;
|
|
25
|
+
this.details = init.details ?? [];
|
|
26
|
+
this.retryable = init.retryable ?? false;
|
|
27
|
+
this.retryAfterSeconds = init.retryAfterSeconds ?? null;
|
|
28
|
+
this.attempts = init.attempts ?? 1;
|
|
29
|
+
}
|
|
30
|
+
toJSON() {
|
|
31
|
+
return { name: this.name, code: this.code, message: this.message, status: this.status, requestId: this.requestId, retryable: this.retryable, attempts: this.attempts };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
exports.CommunicationError = CommunicationError;
|
|
35
|
+
const KNOWN = [
|
|
36
|
+
'invalid_json', 'invalid_credentials', 'forbidden', 'not_found', 'idempotency_conflict', 'payload_too_large', 'unsupported_media_type', 'validation_failed',
|
|
37
|
+
'channel_unavailable', 'tls_required', 'rate_limited', 'internal_error', 'temporarily_unavailable', 'conflict',
|
|
38
|
+
];
|
|
39
|
+
function fromResponse(status, body, headers, attempts) {
|
|
40
|
+
const err = body?.error;
|
|
41
|
+
const apiCode = err?.code ?? null;
|
|
42
|
+
const code = apiCode && KNOWN.includes(apiCode) ? apiCode : 'unknown_error';
|
|
43
|
+
const retryAfter = Number(headers.get('retry-after'));
|
|
44
|
+
return new CommunicationError({
|
|
45
|
+
code, apiCode, status, requestId: err?.requestId ?? headers.get('x-request-id'), details: err?.details ?? [],
|
|
46
|
+
message: err?.message ?? `The request failed with status ${status}`, retryable: status === 429 || status >= 500, retryAfterSeconds: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null, attempts,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_TOLERANCE_SECONDS = void 0;
|
|
4
|
+
exports.verifyEventSignature = verifyEventSignature;
|
|
5
|
+
exports.createEventHandler = createEventHandler;
|
|
6
|
+
const errors_1 = require("./errors");
|
|
7
|
+
exports.DEFAULT_TOLERANCE_SECONDS = 300;
|
|
8
|
+
const toHex = (buf) => Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
9
|
+
async function hmac(secret, data) {
|
|
10
|
+
const key = await globalThis.crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
11
|
+
return toHex(await globalThis.crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)));
|
|
12
|
+
}
|
|
13
|
+
function equal(a, b) {
|
|
14
|
+
let diff = a.length ^ b.length;
|
|
15
|
+
for (let i = 0; i < Math.max(a.length, b.length); i += 1)
|
|
16
|
+
diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
|
17
|
+
return diff === 0;
|
|
18
|
+
}
|
|
19
|
+
/** Verifies `X-Communication-Signature` over `timestamp + "." + rawBody`. Pass the RAW body text, not a parsed object. During secret rotation the header carries two values; either may match. */
|
|
20
|
+
async function verifyEventSignature(input) {
|
|
21
|
+
if (!input.timestamp || !input.signature)
|
|
22
|
+
return { valid: false, reason: 'missing' };
|
|
23
|
+
const ts = Number(input.timestamp);
|
|
24
|
+
if (!Number.isFinite(ts))
|
|
25
|
+
return { valid: false, reason: 'missing' };
|
|
26
|
+
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
27
|
+
if (Math.abs(now - ts) > (input.toleranceSeconds ?? exports.DEFAULT_TOLERANCE_SECONDS))
|
|
28
|
+
return { valid: false, reason: 'stale' };
|
|
29
|
+
const presented = input.signature.split(/[,\s]+/).filter(Boolean);
|
|
30
|
+
for (const secret of Array.isArray(input.secret) ? input.secret : [input.secret]) {
|
|
31
|
+
const expected = `v1=${await hmac(secret, `${input.timestamp}.${input.rawBody}`)}`;
|
|
32
|
+
if (presented.some((p) => equal(p, expected)))
|
|
33
|
+
return { valid: true };
|
|
34
|
+
}
|
|
35
|
+
return { valid: false, reason: 'mismatch' };
|
|
36
|
+
}
|
|
37
|
+
/** A ready-made handler for a Next.js route or any Request/Response runtime. Answers 2xx only after `onEvent` succeeded. */
|
|
38
|
+
function createEventHandler(options) {
|
|
39
|
+
return async (req) => {
|
|
40
|
+
const rawBody = await req.text();
|
|
41
|
+
const verdict = await verifyEventSignature({ secret: options.secret, timestamp: req.headers.get('x-communication-timestamp'), signature: req.headers.get('x-communication-signature'), rawBody, toleranceSeconds: options.toleranceSeconds });
|
|
42
|
+
if (!verdict.valid)
|
|
43
|
+
return new Response(null, { status: 401 });
|
|
44
|
+
let event;
|
|
45
|
+
try {
|
|
46
|
+
event = JSON.parse(rawBody);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return new Response(null, { status: 400 });
|
|
50
|
+
}
|
|
51
|
+
if (event.type !== 'message.state_changed' || event.version !== 1)
|
|
52
|
+
return new Response(null, { status: 204 }); // unknown future types are acknowledged and ignored
|
|
53
|
+
try {
|
|
54
|
+
if (!(await options.alreadyProcessed?.(event)))
|
|
55
|
+
await options.onEvent(event);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
throw new errors_1.CommunicationError({ code: 'unknown_error', message: e instanceof Error ? e.message : 'The event handler failed', retryable: true });
|
|
59
|
+
}
|
|
60
|
+
return new Response(null, { status: 204 });
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.newIdempotencyKey = newIdempotencyKey;
|
|
4
|
+
exports.deriveIdempotencyKey = deriveIdempotencyKey;
|
|
5
|
+
/** A random key for a send you will not retry yourself. Prefer `deriveIdempotencyKey` so your own retries are safe too. */
|
|
6
|
+
function newIdempotencyKey() {
|
|
7
|
+
return `sdk-${globalThis.crypto.randomUUID()}`;
|
|
8
|
+
}
|
|
9
|
+
/** A stable key derived from parts you choose, for example an order id and an event name. Same input, same key. */
|
|
10
|
+
async function deriveIdempotencyKey(...parts) {
|
|
11
|
+
const data = new TextEncoder().encode(parts.map(String).join('\u001f'));
|
|
12
|
+
const hash = await globalThis.crypto.subtle.digest('SHA-256', data);
|
|
13
|
+
return `d-${Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 48)}`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createInboxHandler = exports.createInboxClient = void 0;
|
|
4
|
+
const errors_1 = require("../errors");
|
|
5
|
+
const serverOnly = () => {
|
|
6
|
+
throw new errors_1.CommunicationError({ code: 'server_only', message: 'The Communication inbox client holds a secret key and must run on the server.' });
|
|
7
|
+
};
|
|
8
|
+
/** Selected by the `browser` export condition: the inbox entry never reaches client code. The component talks to the host's own routes instead. */
|
|
9
|
+
exports.createInboxClient = serverOnly;
|
|
10
|
+
exports.createInboxHandler = serverOnly;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.personHeader = personHeader;
|
|
4
|
+
exports.createInboxClient = createInboxClient;
|
|
5
|
+
const client_1 = require("../client");
|
|
6
|
+
/** `X-Communication-Person` is base64url of the UTF-8 person id, so any identifier travels safely in a header. */
|
|
7
|
+
function personHeader(personId) {
|
|
8
|
+
let binary = '';
|
|
9
|
+
for (const byte of new TextEncoder().encode(personId))
|
|
10
|
+
binary += String.fromCharCode(byte);
|
|
11
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
12
|
+
}
|
|
13
|
+
const isClient = (v) => client_1.TRANSPORT in v;
|
|
14
|
+
/**
|
|
15
|
+
* The in-app inbox of one person. Pass an existing client to share its key and settings, or the same options as
|
|
16
|
+
* `createCommunicationClient`. The key needs the scopes `inbox:read` and `inbox:write`.
|
|
17
|
+
*/
|
|
18
|
+
function createInboxClient(client = {}) {
|
|
19
|
+
const request = (isClient(client) ? client : (0, client_1.createCommunicationClient)(client))[client_1.TRANSPORT];
|
|
20
|
+
const call = (method, path, personId, body, read, options = {}) => request(method, path, body, { headers: { 'X-Communication-Person': personHeader(personId) }, retry: options.retry ?? read, timeoutMs: options.timeoutMs }).then((r) => r.data);
|
|
21
|
+
return {
|
|
22
|
+
summary: (personId, options) => call('GET', '/inbox/summary', personId, undefined, true, options),
|
|
23
|
+
list(personId, query = {}, options) {
|
|
24
|
+
const q = new URLSearchParams();
|
|
25
|
+
if (query.limit !== undefined)
|
|
26
|
+
q.set('limit', String(query.limit));
|
|
27
|
+
if (query.cursor)
|
|
28
|
+
q.set('cursor', query.cursor);
|
|
29
|
+
if (query.unreadOnly)
|
|
30
|
+
q.set('unread', 'true');
|
|
31
|
+
const qs = q.toString();
|
|
32
|
+
return call('GET', `/inbox/notifications${qs ? `?${qs}` : ''}`, personId, undefined, true, options);
|
|
33
|
+
},
|
|
34
|
+
markRead: (personId, id, options) => call('POST', `/inbox/notifications/${encodeURIComponent(id)}/read`, personId, {}, false, options),
|
|
35
|
+
markAllRead: (personId, input = {}, options) => call('POST', '/inbox/read-all', personId, input.upTo ? { upTo: input.upTo } : {}, false, options),
|
|
36
|
+
getPreference: (personId, options) => call('GET', '/inbox/preference', personId, undefined, true, options),
|
|
37
|
+
setPreference: (personId, state, options) => call('PUT', '/inbox/preference', personId, { state }, false, options),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HOST_ERRORS = void 0;
|
|
4
|
+
exports.hostCodeOf = hostCodeOf;
|
|
5
|
+
exports.hostError = hostError;
|
|
6
|
+
exports.hostData = hostData;
|
|
7
|
+
const errors_1 = require("../errors");
|
|
8
|
+
/** The six answers a browser can get from the host's inbox routes. The messages are fixed: nothing from upstream is repeated. */
|
|
9
|
+
exports.HOST_ERRORS = {
|
|
10
|
+
not_signed_in: { status: 401, message: 'Sign in to see your notifications.' },
|
|
11
|
+
not_permitted: { status: 403, message: 'This request is not allowed.' },
|
|
12
|
+
not_found: { status: 404, message: 'This notification is not available.' },
|
|
13
|
+
invalid_input: { status: 422, message: 'The request is not valid.' },
|
|
14
|
+
rate_limited: { status: 429, message: 'Too many requests. Try again in a moment.' },
|
|
15
|
+
unavailable: { status: 503, message: 'Notifications are not available right now.' },
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Total: every failure is one of the six codes. A revoked or misconfigured key, a timeout and anything unknown are
|
|
19
|
+
* `unavailable`, because the person in the browser cannot fix them and must not be told about them.
|
|
20
|
+
*/
|
|
21
|
+
function hostCodeOf(error) {
|
|
22
|
+
if (!(error instanceof errors_1.CommunicationError))
|
|
23
|
+
return 'unavailable';
|
|
24
|
+
switch (error.code) {
|
|
25
|
+
case 'forbidden':
|
|
26
|
+
return 'not_permitted';
|
|
27
|
+
case 'not_found':
|
|
28
|
+
return 'not_found';
|
|
29
|
+
case 'validation_failed':
|
|
30
|
+
return 'invalid_input';
|
|
31
|
+
case 'rate_limited':
|
|
32
|
+
return 'rate_limited';
|
|
33
|
+
default:
|
|
34
|
+
return 'unavailable';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' };
|
|
38
|
+
function hostError(code, field) {
|
|
39
|
+
const body = { error: { code, message: exports.HOST_ERRORS[code].message, ...(field ? { field } : {}) } };
|
|
40
|
+
return new Response(JSON.stringify(body), { status: exports.HOST_ERRORS[code].status, headers: HEADERS });
|
|
41
|
+
}
|
|
42
|
+
function hostData(data) {
|
|
43
|
+
return new Response(JSON.stringify({ data }), { status: 200, headers: HEADERS });
|
|
44
|
+
}
|