@xtremax/pigeon 0.0.0-stage → 0.3.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +56 -2
  3. package/dist/cjs/browser-stub.js +10 -0
  4. package/dist/cjs/client.js +112 -0
  5. package/dist/cjs/contract.js +5 -0
  6. package/dist/cjs/errors.js +48 -0
  7. package/dist/cjs/events.js +62 -0
  8. package/dist/cjs/idempotency.js +14 -0
  9. package/dist/cjs/inbox/browser-stub.js +10 -0
  10. package/dist/cjs/inbox/client.js +39 -0
  11. package/dist/cjs/inbox/errors.js +44 -0
  12. package/dist/cjs/inbox/handler.js +127 -0
  13. package/dist/cjs/inbox/index.js +15 -0
  14. package/dist/cjs/inbox/origin.js +22 -0
  15. package/dist/cjs/inbox/project.js +84 -0
  16. package/dist/cjs/inbox/validate.js +59 -0
  17. package/dist/cjs/inbox-react/format.js +72 -0
  18. package/dist/cjs/inbox-react/inbox-client.js +63 -0
  19. package/dist/cjs/inbox-react/inbox-context.js +13 -0
  20. package/dist/cjs/inbox-react/inbox-full-list.js +13 -0
  21. package/dist/cjs/inbox-react/inbox-list.js +37 -0
  22. package/dist/cjs/inbox-react/inbox-panel.js +13 -0
  23. package/dist/cjs/inbox-react/inbox-provider.js +157 -0
  24. package/dist/cjs/inbox-react/inbox-state.js +88 -0
  25. package/dist/cjs/inbox-react/index.js +25 -0
  26. package/dist/cjs/inbox-react/labels.js +38 -0
  27. package/dist/cjs/inbox-react/notification-bell.js +32 -0
  28. package/dist/cjs/inbox-react/notification-item.js +17 -0
  29. package/dist/cjs/inbox-react/parse.js +94 -0
  30. package/dist/cjs/inbox-react/preference-dialog.js +49 -0
  31. package/dist/cjs/inbox-react/types.js +4 -0
  32. package/dist/cjs/inbox-react/use-inbox-polling.js +94 -0
  33. package/dist/cjs/index.js +14 -0
  34. package/dist/cjs/package.json +1 -0
  35. package/dist/cjs/retry.js +14 -0
  36. package/dist/esm/browser-stub.js +6 -0
  37. package/dist/esm/client.js +107 -0
  38. package/dist/esm/contract.js +2 -0
  39. package/dist/esm/errors.js +42 -0
  40. package/dist/esm/events.js +57 -0
  41. package/dist/esm/idempotency.js +10 -0
  42. package/dist/esm/inbox/browser-stub.js +7 -0
  43. package/dist/esm/inbox/client.js +35 -0
  44. package/dist/esm/inbox/errors.js +38 -0
  45. package/dist/esm/inbox/handler.js +124 -0
  46. package/dist/esm/inbox/index.js +5 -0
  47. package/dist/esm/inbox/origin.js +19 -0
  48. package/dist/esm/inbox/project.js +76 -0
  49. package/dist/esm/inbox/validate.js +49 -0
  50. package/dist/esm/inbox-react/format.js +64 -0
  51. package/dist/esm/inbox-react/inbox-client.js +58 -0
  52. package/dist/esm/inbox-react/inbox-context.js +9 -0
  53. package/dist/esm/inbox-react/inbox-full-list.js +10 -0
  54. package/dist/esm/inbox-react/inbox-list.js +34 -0
  55. package/dist/esm/inbox-react/inbox-panel.js +10 -0
  56. package/dist/esm/inbox-react/inbox-provider.js +154 -0
  57. package/dist/esm/inbox-react/inbox-state.js +83 -0
  58. package/dist/esm/inbox-react/index.js +18 -0
  59. package/dist/esm/inbox-react/labels.js +34 -0
  60. package/dist/esm/inbox-react/notification-bell.js +29 -0
  61. package/dist/esm/inbox-react/notification-item.js +14 -0
  62. package/dist/esm/inbox-react/parse.js +85 -0
  63. package/dist/esm/inbox-react/preference-dialog.js +46 -0
  64. package/dist/esm/inbox-react/types.js +1 -0
  65. package/dist/esm/inbox-react/use-inbox-polling.js +88 -0
  66. package/dist/esm/index.js +4 -0
  67. package/dist/esm/retry.js +9 -0
  68. package/dist/inbox-react/styles.css +65 -0
  69. package/dist/types/browser-stub.d.ts +4 -0
  70. package/dist/types/client.d.ts +94 -0
  71. package/dist/types/contract.d.ts +182 -0
  72. package/dist/types/errors.d.ts +29 -0
  73. package/dist/types/events.d.ts +26 -0
  74. package/dist/types/idempotency.d.ts +4 -0
  75. package/dist/types/inbox/browser-stub.d.ts +3 -0
  76. package/dist/types/inbox/client.d.ts +34 -0
  77. package/dist/types/inbox/errors.d.ts +13 -0
  78. package/dist/types/inbox/handler.d.ts +25 -0
  79. package/dist/types/inbox/index.d.ts +6 -0
  80. package/dist/types/inbox/origin.d.ts +5 -0
  81. package/dist/types/inbox/project.d.ts +7 -0
  82. package/dist/types/inbox/validate.d.ts +25 -0
  83. package/dist/types/inbox-react/format.d.ts +13 -0
  84. package/dist/types/inbox-react/inbox-client.d.ts +24 -0
  85. package/dist/types/inbox-react/inbox-context.d.ts +29 -0
  86. package/dist/types/inbox-react/inbox-full-list.d.ts +7 -0
  87. package/dist/types/inbox-react/inbox-list.d.ts +12 -0
  88. package/dist/types/inbox-react/inbox-panel.d.ts +2 -0
  89. package/dist/types/inbox-react/inbox-provider.d.ts +18 -0
  90. package/dist/types/inbox-react/inbox-state.d.ts +70 -0
  91. package/dist/types/inbox-react/index.d.ts +19 -0
  92. package/dist/types/inbox-react/labels.d.ts +40 -0
  93. package/dist/types/inbox-react/notification-bell.d.ts +4 -0
  94. package/dist/types/inbox-react/notification-item.d.ts +9 -0
  95. package/dist/types/inbox-react/parse.d.ts +9 -0
  96. package/dist/types/inbox-react/preference-dialog.d.ts +5 -0
  97. package/dist/types/inbox-react/types.d.ts +55 -0
  98. package/dist/types/inbox-react/use-inbox-polling.d.ts +33 -0
  99. package/dist/types/index.d.ts +6 -0
  100. package/dist/types/retry.d.ts +13 -0
  101. package/package.json +33 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+ - 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.
5
+ - 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.
6
+ - `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`.
7
+ - The ESM build loads in plain Node: relative imports carry their file extension.
8
+ - 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`.
9
+
10
+ ## 0.2.0
11
+ - 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.
12
+ - `ping()` also answers `subKey`: the sub-tenant the key belongs to, whose notifications the inbox calls return.
13
+ - Inbox types in `/contract`: `InboxNotification`, `InboxSummary`, `InboxNotificationPage`, `InboxPreference`, `InboxHostErrorCode`.
14
+ - No change to existing calls.
15
+
16
+ ## 0.1.0
17
+ - First release: `createCommunicationClient` (messages, channels, ping, recipients, preferences, message classes), `createEventHandler`, `verifyEventSignature`, `deriveIdempotencyKey`, contract types.
package/README.md CHANGED
@@ -1,3 +1,57 @@
1
- # Temporary Holding Version
1
+ # @xtremax/pigeon
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ 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.
@@ -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,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SUPPORTED_API_VERSION = void 0;
4
+ /** Types mirrored from the Gateway's external API (types only; safe in any bundle). */
5
+ exports.SUPPORTED_API_VERSION = 'v1';
@@ -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
+ }
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createInboxHandler = createInboxHandler;
4
+ const client_1 = require("./client");
5
+ const errors_1 = require("./errors");
6
+ const origin_1 = require("./origin");
7
+ const project_1 = require("./project");
8
+ const validate_1 = require("./validate");
9
+ /** The six operations of IB-8, and nothing else. */
10
+ function match(method, pathname, base) {
11
+ if (pathname !== base && !pathname.startsWith(`${base}/`))
12
+ return null;
13
+ const rest = pathname.slice(base.length).replace(/\/+$/, '');
14
+ if (method === 'GET' && rest === '/summary')
15
+ return { name: 'summary', write: false };
16
+ if (method === 'GET' && rest === '/notifications')
17
+ return { name: 'list', write: false };
18
+ if (method === 'GET' && rest === '/preference')
19
+ return { name: 'getPreference', write: false };
20
+ if (method === 'PUT' && rest === '/preference')
21
+ return { name: 'setPreference', write: true };
22
+ if (method === 'POST' && rest === '/read-all')
23
+ return { name: 'readAll', write: true };
24
+ const read = method === 'POST' ? /^\/notifications\/([^/]+)\/read$/.exec(rest) : null;
25
+ if (read)
26
+ return { name: 'markRead', write: true, id: read[1] };
27
+ return null;
28
+ }
29
+ /**
30
+ * The host xApp's browser-facing inbox routes, in one tested function. Mount it on a catch-all route:
31
+ *
32
+ * const handler = createInboxHandler({ resolvePerson });
33
+ * export { handler as GET, handler as POST, handler as PUT };
34
+ *
35
+ * The person comes only from `resolvePerson`. The host stores nothing. Without `client`, the key and the address are
36
+ * read on the first request, so a missing one answers `unavailable` and never stops the host's build.
37
+ */
38
+ function createInboxHandler(options) {
39
+ const base = (options.basePath ?? '/api/communication/inbox').replace(/\/+$/, '');
40
+ const call = { retry: false, timeoutMs: options.timeoutMs ?? 8000 };
41
+ let made;
42
+ const clientOf = () => options.client ?? (made ??= (0, client_1.createInboxClient)());
43
+ return async function inboxHandler(request) {
44
+ try {
45
+ const url = new URL(request.url);
46
+ const op = match(request.method.toUpperCase(), url.pathname, base);
47
+ if (!op)
48
+ return (0, errors_1.hostError)('not_found');
49
+ if (op.write && !(0, origin_1.originAllowed)(request, options.allowedOrigins))
50
+ return (0, errors_1.hostError)('not_permitted');
51
+ const person = await options.resolvePerson(request);
52
+ if (typeof person !== 'string' || person.length === 0 || person.length > validate_1.INBOX_LIMITS.personMax)
53
+ return (0, errors_1.hostError)('not_signed_in');
54
+ const client = clientOf();
55
+ switch (op.name) {
56
+ case 'summary':
57
+ return answer((0, project_1.projectSummary)(await client.summary(person, call)));
58
+ case 'list': {
59
+ const limit = (0, validate_1.parseLimit)(url.searchParams.get('limit'));
60
+ if (!limit.ok)
61
+ return (0, errors_1.hostError)('invalid_input', limit.field);
62
+ const cursor = (0, validate_1.parseCursor)(url.searchParams.get('cursor'));
63
+ if (!cursor.ok)
64
+ return (0, errors_1.hostError)('invalid_input', cursor.field);
65
+ const unread = (0, validate_1.parseUnread)(url.searchParams.get('unread'));
66
+ if (!unread.ok)
67
+ return (0, errors_1.hostError)('invalid_input', unread.field);
68
+ return answer((0, project_1.projectPage)(await client.list(person, { limit: limit.value, cursor: cursor.value, unreadOnly: unread.value }, call)));
69
+ }
70
+ case 'markRead': {
71
+ let raw;
72
+ try {
73
+ raw = decodeURIComponent(op.id);
74
+ }
75
+ catch {
76
+ return (0, errors_1.hostError)('invalid_input', 'id');
77
+ }
78
+ const id = (0, validate_1.parseId)(raw);
79
+ if (!id.ok)
80
+ return (0, errors_1.hostError)('invalid_input', id.field);
81
+ return answer((0, project_1.projectMarkRead)(await client.markRead(person, id.value, call)));
82
+ }
83
+ case 'readAll': {
84
+ const body = (0, validate_1.parseBody)(await request.text());
85
+ if (!body.ok)
86
+ return (0, errors_1.hostError)('invalid_input', body.field);
87
+ const upTo = (0, validate_1.parseUpTo)(body.value.upTo);
88
+ if (!upTo.ok)
89
+ return (0, errors_1.hostError)('invalid_input', upTo.field);
90
+ return answer((0, project_1.projectReadAll)(await client.markAllRead(person, { upTo: upTo.value }, call)));
91
+ }
92
+ case 'getPreference':
93
+ return answer((0, project_1.projectPreference)(await client.getPreference(person, call)));
94
+ case 'setPreference': {
95
+ const body = (0, validate_1.parseBody)(await request.text());
96
+ if (!body.ok)
97
+ return (0, errors_1.hostError)('invalid_input', body.field);
98
+ const state = (0, validate_1.parseState)(body.value.state);
99
+ if (!state.ok)
100
+ return (0, errors_1.hostError)('invalid_input', state.field);
101
+ return answer((0, project_1.projectPreference)(await client.setPreference(person, state.value, call)));
102
+ }
103
+ }
104
+ }
105
+ catch (error) {
106
+ const code = (0, errors_1.hostCodeOf)(error);
107
+ if (code === 'unavailable')
108
+ report(error);
109
+ return (0, errors_1.hostError)(code);
110
+ }
111
+ };
112
+ function answer(data) {
113
+ if (data === null) {
114
+ report(new Error('The inbox answer did not have the documented shape'));
115
+ return (0, errors_1.hostError)('unavailable');
116
+ }
117
+ return (0, errors_1.hostData)(data);
118
+ }
119
+ function report(error) {
120
+ try {
121
+ options.onError?.(error);
122
+ }
123
+ catch {
124
+ // A failing logger must not change the answer.
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.INBOX_LIMITS = exports.originAllowed = exports.hostCodeOf = exports.HOST_ERRORS = exports.createInboxHandler = exports.personHeader = exports.createInboxClient = void 0;
4
+ var client_1 = require("./client");
5
+ Object.defineProperty(exports, "createInboxClient", { enumerable: true, get: function () { return client_1.createInboxClient; } });
6
+ Object.defineProperty(exports, "personHeader", { enumerable: true, get: function () { return client_1.personHeader; } });
7
+ var handler_1 = require("./handler");
8
+ Object.defineProperty(exports, "createInboxHandler", { enumerable: true, get: function () { return handler_1.createInboxHandler; } });
9
+ var errors_1 = require("./errors");
10
+ Object.defineProperty(exports, "HOST_ERRORS", { enumerable: true, get: function () { return errors_1.HOST_ERRORS; } });
11
+ Object.defineProperty(exports, "hostCodeOf", { enumerable: true, get: function () { return errors_1.hostCodeOf; } });
12
+ var origin_1 = require("./origin");
13
+ Object.defineProperty(exports, "originAllowed", { enumerable: true, get: function () { return origin_1.originAllowed; } });
14
+ var validate_1 = require("./validate");
15
+ Object.defineProperty(exports, "INBOX_LIMITS", { enumerable: true, get: function () { return validate_1.INBOX_LIMITS; } });
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.originAllowed = originAllowed;
4
+ /**
5
+ * A request that changes something must come from the host's own pages (14.5): the browser says `Sec-Fetch-Site: same-origin`,
6
+ * or the `Origin` header equals an allowed origin. Without `allowedOrigins`, the only allowed origin is the request's own.
7
+ */
8
+ function originAllowed(request, allowedOrigins) {
9
+ if (request.headers.get('sec-fetch-site') === 'same-origin')
10
+ return true;
11
+ const origin = request.headers.get('origin');
12
+ if (!origin)
13
+ return false;
14
+ let allowed;
15
+ try {
16
+ allowed = allowedOrigins && allowedOrigins.length > 0 ? allowedOrigins : [new URL(request.url).origin];
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ return allowed.includes(origin);
22
+ }