@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +68 -2
  3. package/bin/pigeon.mjs +92 -0
  4. package/dist/cjs/browser-stub.js +10 -0
  5. package/dist/cjs/client.js +112 -0
  6. package/dist/cjs/contract.js +5 -0
  7. package/dist/cjs/errors.js +48 -0
  8. package/dist/cjs/events.js +62 -0
  9. package/dist/cjs/idempotency.js +14 -0
  10. package/dist/cjs/inbox/browser-stub.js +10 -0
  11. package/dist/cjs/inbox/client.js +39 -0
  12. package/dist/cjs/inbox/errors.js +44 -0
  13. package/dist/cjs/inbox/handler.js +127 -0
  14. package/dist/cjs/inbox/index.js +15 -0
  15. package/dist/cjs/inbox/origin.js +22 -0
  16. package/dist/cjs/inbox/project.js +84 -0
  17. package/dist/cjs/inbox/validate.js +59 -0
  18. package/dist/cjs/inbox-react/format.js +72 -0
  19. package/dist/cjs/inbox-react/inbox-client.js +63 -0
  20. package/dist/cjs/inbox-react/inbox-context.js +13 -0
  21. package/dist/cjs/inbox-react/inbox-full-list.js +13 -0
  22. package/dist/cjs/inbox-react/inbox-list.js +37 -0
  23. package/dist/cjs/inbox-react/inbox-panel.js +13 -0
  24. package/dist/cjs/inbox-react/inbox-provider.js +157 -0
  25. package/dist/cjs/inbox-react/inbox-state.js +88 -0
  26. package/dist/cjs/inbox-react/index.js +25 -0
  27. package/dist/cjs/inbox-react/labels.js +38 -0
  28. package/dist/cjs/inbox-react/notification-bell.js +32 -0
  29. package/dist/cjs/inbox-react/notification-item.js +17 -0
  30. package/dist/cjs/inbox-react/parse.js +94 -0
  31. package/dist/cjs/inbox-react/preference-dialog.js +49 -0
  32. package/dist/cjs/inbox-react/types.js +4 -0
  33. package/dist/cjs/inbox-react/use-inbox-polling.js +94 -0
  34. package/dist/cjs/index.js +14 -0
  35. package/dist/cjs/package.json +1 -0
  36. package/dist/cjs/retry.js +14 -0
  37. package/dist/esm/browser-stub.js +6 -0
  38. package/dist/esm/client.js +107 -0
  39. package/dist/esm/contract.js +2 -0
  40. package/dist/esm/errors.js +42 -0
  41. package/dist/esm/events.js +57 -0
  42. package/dist/esm/idempotency.js +10 -0
  43. package/dist/esm/inbox/browser-stub.js +7 -0
  44. package/dist/esm/inbox/client.js +35 -0
  45. package/dist/esm/inbox/errors.js +38 -0
  46. package/dist/esm/inbox/handler.js +124 -0
  47. package/dist/esm/inbox/index.js +5 -0
  48. package/dist/esm/inbox/origin.js +19 -0
  49. package/dist/esm/inbox/project.js +76 -0
  50. package/dist/esm/inbox/validate.js +49 -0
  51. package/dist/esm/inbox-react/format.js +64 -0
  52. package/dist/esm/inbox-react/inbox-client.js +58 -0
  53. package/dist/esm/inbox-react/inbox-context.js +9 -0
  54. package/dist/esm/inbox-react/inbox-full-list.js +10 -0
  55. package/dist/esm/inbox-react/inbox-list.js +34 -0
  56. package/dist/esm/inbox-react/inbox-panel.js +10 -0
  57. package/dist/esm/inbox-react/inbox-provider.js +154 -0
  58. package/dist/esm/inbox-react/inbox-state.js +83 -0
  59. package/dist/esm/inbox-react/index.js +18 -0
  60. package/dist/esm/inbox-react/labels.js +34 -0
  61. package/dist/esm/inbox-react/notification-bell.js +29 -0
  62. package/dist/esm/inbox-react/notification-item.js +14 -0
  63. package/dist/esm/inbox-react/parse.js +85 -0
  64. package/dist/esm/inbox-react/preference-dialog.js +46 -0
  65. package/dist/esm/inbox-react/types.js +1 -0
  66. package/dist/esm/inbox-react/use-inbox-polling.js +88 -0
  67. package/dist/esm/index.js +4 -0
  68. package/dist/esm/retry.js +9 -0
  69. package/dist/inbox-react/styles.css +65 -0
  70. package/dist/types/browser-stub.d.ts +4 -0
  71. package/dist/types/client.d.ts +94 -0
  72. package/dist/types/contract.d.ts +182 -0
  73. package/dist/types/errors.d.ts +29 -0
  74. package/dist/types/events.d.ts +26 -0
  75. package/dist/types/idempotency.d.ts +4 -0
  76. package/dist/types/inbox/browser-stub.d.ts +3 -0
  77. package/dist/types/inbox/client.d.ts +34 -0
  78. package/dist/types/inbox/errors.d.ts +13 -0
  79. package/dist/types/inbox/handler.d.ts +25 -0
  80. package/dist/types/inbox/index.d.ts +6 -0
  81. package/dist/types/inbox/origin.d.ts +5 -0
  82. package/dist/types/inbox/project.d.ts +7 -0
  83. package/dist/types/inbox/validate.d.ts +25 -0
  84. package/dist/types/inbox-react/format.d.ts +13 -0
  85. package/dist/types/inbox-react/inbox-client.d.ts +24 -0
  86. package/dist/types/inbox-react/inbox-context.d.ts +29 -0
  87. package/dist/types/inbox-react/inbox-full-list.d.ts +7 -0
  88. package/dist/types/inbox-react/inbox-list.d.ts +12 -0
  89. package/dist/types/inbox-react/inbox-panel.d.ts +2 -0
  90. package/dist/types/inbox-react/inbox-provider.d.ts +18 -0
  91. package/dist/types/inbox-react/inbox-state.d.ts +70 -0
  92. package/dist/types/inbox-react/index.d.ts +19 -0
  93. package/dist/types/inbox-react/labels.d.ts +40 -0
  94. package/dist/types/inbox-react/notification-bell.d.ts +4 -0
  95. package/dist/types/inbox-react/notification-item.d.ts +9 -0
  96. package/dist/types/inbox-react/parse.d.ts +9 -0
  97. package/dist/types/inbox-react/preference-dialog.d.ts +5 -0
  98. package/dist/types/inbox-react/types.d.ts +55 -0
  99. package/dist/types/inbox-react/use-inbox-polling.d.ts +33 -0
  100. package/dist/types/index.d.ts +6 -0
  101. package/dist/types/retry.d.ts +13 -0
  102. package/package.json +34 -4
  103. package/skills/add-pigeon-notifications/SKILL.md +302 -0
  104. package/skills/add-pigeon-notifications/references/inbox-contract.instructions.md +208 -0
@@ -0,0 +1,107 @@
1
+ import { CommunicationError, fromResponse, redact } from './errors.js';
2
+ import { newIdempotencyKey } from './idempotency.js';
3
+ import { DEFAULT_RETRY, backoffMs, isRetryableStatus } from './retry.js';
4
+ /** Where a client keeps its transport, so `@xtremax/pigeon/inbox` can reuse the key handling, retries and errors. */
5
+ export const TRANSPORT = Symbol.for('@xtremax/pigeon.transport');
6
+ const sleepDefault = (ms) => new Promise((r) => setTimeout(r, ms));
7
+ export function createCommunicationClient(options = {}) {
8
+ if (typeof window !== 'undefined' && typeof globalThis.document !== 'undefined') {
9
+ throw new CommunicationError({ code: 'server_only', message: 'The Communication client holds a secret key and must run on the server.' });
10
+ }
11
+ const env = typeof process !== 'undefined' ? process.env : {};
12
+ const apiKey = options.apiKey ?? env.COMMUNICATION_API_KEY;
13
+ const baseUrl = (options.baseUrl ?? env.COMMUNICATION_URL ?? '').replace(/\/$/, '');
14
+ if (!apiKey || !baseUrl)
15
+ throw new CommunicationError({ code: 'not_configured', message: 'Set COMMUNICATION_API_KEY and COMMUNICATION_URL (the connector provides both).' });
16
+ const policy = { ...DEFAULT_RETRY, ...options.retry };
17
+ const doFetch = options.fetch ?? fetch;
18
+ const sleep = options.sleep ?? sleepDefault;
19
+ async function request(method, path, body, opts = {}) {
20
+ const started = Date.now();
21
+ const max = opts.retry === false ? 1 : policy.maxAttempts;
22
+ const totalMs = Math.min(policy.totalTimeoutMs, opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : policy.totalTimeoutMs);
23
+ let last = null;
24
+ for (let attempt = 1; attempt <= max; attempt += 1) {
25
+ const ctrl = new AbortController();
26
+ const timer = setTimeout(() => ctrl.abort(), Math.min(policy.perAttemptTimeoutMs, Math.max(1, totalMs - (Date.now() - started))));
27
+ try {
28
+ const res = await doFetch(`${baseUrl}/api/external/v1${path}`, {
29
+ method,
30
+ headers: { ...(opts.headers ?? {}), Authorization: `Bearer ${apiKey}`, Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
31
+ body: body === undefined ? undefined : JSON.stringify(body),
32
+ signal: ctrl.signal,
33
+ redirect: 'error',
34
+ });
35
+ const text = await res.text();
36
+ let parsed = null;
37
+ try {
38
+ parsed = text ? JSON.parse(text) : null;
39
+ }
40
+ catch {
41
+ if (res.ok)
42
+ throw new CommunicationError({ code: 'invalid_response', message: 'The service answered with something that is not JSON', status: res.status, attempts: attempt });
43
+ }
44
+ if (res.ok)
45
+ return { data: parsed.data, headers: res.headers };
46
+ last = fromResponse(res.status, parsed, res.headers, attempt);
47
+ if (!isRetryableStatus(res.status))
48
+ throw last;
49
+ }
50
+ catch (e) {
51
+ if (e instanceof CommunicationError) {
52
+ if (!e.retryable || e.code === 'invalid_response')
53
+ throw e;
54
+ last = e;
55
+ }
56
+ else {
57
+ const aborted = e.name === 'AbortError';
58
+ last = new CommunicationError({ code: aborted ? 'timeout' : 'network_error', message: aborted ? 'The request timed out' : 'The service could not be reached', retryable: true, attempts: attempt });
59
+ }
60
+ }
61
+ finally {
62
+ clearTimeout(timer);
63
+ }
64
+ const wait = backoffMs(attempt, policy, last?.retryAfterSeconds ?? null, options.random);
65
+ if (attempt >= max || Date.now() - started + wait >= totalMs)
66
+ break;
67
+ await sleep(wait);
68
+ }
69
+ throw new 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) });
70
+ }
71
+ const put = (path, body) => request('PUT', path, body).then((r) => r.data);
72
+ const enc = encodeURIComponent;
73
+ return {
74
+ messages: {
75
+ /** Sends are retried safely because every request carries an idempotency key (yours, or a random one). */
76
+ async send(req) {
77
+ const { data } = await request('POST', '/messages', { ...req, idempotencyKey: req.idempotencyKey ?? newIdempotencyKey() });
78
+ return data;
79
+ },
80
+ get: (id) => request('GET', `/messages/${enc(id)}`).then((r) => r.data),
81
+ },
82
+ channels: { list: () => request('GET', '/channels').then((r) => r.data) },
83
+ ping: () => request('GET', '/ping').then((r) => r.data),
84
+ recipients: {
85
+ put: (personId, body) => put(`/recipients/${enc(personId)}`, body),
86
+ get: (personId) => request('GET', `/recipients/${enc(personId)}`).then((r) => r.data),
87
+ async delete(personId) {
88
+ await request('DELETE', `/recipients/${enc(personId)}`);
89
+ },
90
+ putAddress: (personId, body) => put(`/recipients/${enc(personId)}/addresses`, body),
91
+ async deleteAddress(personId, addressId) {
92
+ await request('DELETE', `/recipients/${enc(personId)}/addresses/${enc(addressId)}`);
93
+ },
94
+ },
95
+ preferences: {
96
+ record: (body) => request('POST', '/preferences', body).then((r) => r.data),
97
+ },
98
+ messageClasses: {
99
+ put: (templateKey, body) => put(`/message-classes/${enc(templateKey)}`, body),
100
+ list: () => request('GET', '/message-classes').then((r) => r.data),
101
+ },
102
+ /** For logs: never prints the key. */
103
+ describe: () => redact(`CommunicationClient(${baseUrl})`),
104
+ [TRANSPORT]: request,
105
+ };
106
+ }
107
+ export { CommunicationError } from './errors.js';
@@ -0,0 +1,2 @@
1
+ /** Types mirrored from the Gateway's external API (types only; safe in any bundle). */
2
+ export const SUPPORTED_API_VERSION = 'v1';
@@ -0,0 +1,42 @@
1
+ const KEY_PATTERN = /cgk_[A-Za-z0-9]{8}_[A-Za-z0-9_-]{43}/g;
2
+ /** Removes anything shaped like a key from text that may be logged. */
3
+ export const redact = (text) => text.replace(KEY_PATTERN, 'cgk_[redacted]').replace(/Bearer\s+\S+/gi, 'Bearer [redacted]');
4
+ export class CommunicationError extends Error {
5
+ code;
6
+ apiCode;
7
+ status;
8
+ requestId;
9
+ details;
10
+ retryable;
11
+ retryAfterSeconds;
12
+ attempts;
13
+ constructor(init) {
14
+ super(redact(init.message));
15
+ this.name = 'CommunicationError';
16
+ this.code = init.code;
17
+ this.apiCode = init.apiCode ?? null;
18
+ this.status = init.status ?? null;
19
+ this.requestId = init.requestId ?? null;
20
+ this.details = init.details ?? [];
21
+ this.retryable = init.retryable ?? false;
22
+ this.retryAfterSeconds = init.retryAfterSeconds ?? null;
23
+ this.attempts = init.attempts ?? 1;
24
+ }
25
+ toJSON() {
26
+ return { name: this.name, code: this.code, message: this.message, status: this.status, requestId: this.requestId, retryable: this.retryable, attempts: this.attempts };
27
+ }
28
+ }
29
+ const KNOWN = [
30
+ 'invalid_json', 'invalid_credentials', 'forbidden', 'not_found', 'idempotency_conflict', 'payload_too_large', 'unsupported_media_type', 'validation_failed',
31
+ 'channel_unavailable', 'tls_required', 'rate_limited', 'internal_error', 'temporarily_unavailable', 'conflict',
32
+ ];
33
+ export function fromResponse(status, body, headers, attempts) {
34
+ const err = body?.error;
35
+ const apiCode = err?.code ?? null;
36
+ const code = apiCode && KNOWN.includes(apiCode) ? apiCode : 'unknown_error';
37
+ const retryAfter = Number(headers.get('retry-after'));
38
+ return new CommunicationError({
39
+ code, apiCode, status, requestId: err?.requestId ?? headers.get('x-request-id'), details: err?.details ?? [],
40
+ message: err?.message ?? `The request failed with status ${status}`, retryable: status === 429 || status >= 500, retryAfterSeconds: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null, attempts,
41
+ });
42
+ }
@@ -0,0 +1,57 @@
1
+ import { CommunicationError } from './errors.js';
2
+ export const DEFAULT_TOLERANCE_SECONDS = 300;
3
+ const toHex = (buf) => Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
4
+ async function hmac(secret, data) {
5
+ const key = await globalThis.crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
6
+ return toHex(await globalThis.crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)));
7
+ }
8
+ function equal(a, b) {
9
+ let diff = a.length ^ b.length;
10
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1)
11
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
12
+ return diff === 0;
13
+ }
14
+ /** 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. */
15
+ export async function verifyEventSignature(input) {
16
+ if (!input.timestamp || !input.signature)
17
+ return { valid: false, reason: 'missing' };
18
+ const ts = Number(input.timestamp);
19
+ if (!Number.isFinite(ts))
20
+ return { valid: false, reason: 'missing' };
21
+ const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
22
+ if (Math.abs(now - ts) > (input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS))
23
+ return { valid: false, reason: 'stale' };
24
+ const presented = input.signature.split(/[,\s]+/).filter(Boolean);
25
+ for (const secret of Array.isArray(input.secret) ? input.secret : [input.secret]) {
26
+ const expected = `v1=${await hmac(secret, `${input.timestamp}.${input.rawBody}`)}`;
27
+ if (presented.some((p) => equal(p, expected)))
28
+ return { valid: true };
29
+ }
30
+ return { valid: false, reason: 'mismatch' };
31
+ }
32
+ /** A ready-made handler for a Next.js route or any Request/Response runtime. Answers 2xx only after `onEvent` succeeded. */
33
+ export function createEventHandler(options) {
34
+ return async (req) => {
35
+ const rawBody = await req.text();
36
+ 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 });
37
+ if (!verdict.valid)
38
+ return new Response(null, { status: 401 });
39
+ let event;
40
+ try {
41
+ event = JSON.parse(rawBody);
42
+ }
43
+ catch {
44
+ return new Response(null, { status: 400 });
45
+ }
46
+ if (event.type !== 'message.state_changed' || event.version !== 1)
47
+ return new Response(null, { status: 204 }); // unknown future types are acknowledged and ignored
48
+ try {
49
+ if (!(await options.alreadyProcessed?.(event)))
50
+ await options.onEvent(event);
51
+ }
52
+ catch (e) {
53
+ throw new CommunicationError({ code: 'unknown_error', message: e instanceof Error ? e.message : 'The event handler failed', retryable: true });
54
+ }
55
+ return new Response(null, { status: 204 });
56
+ };
57
+ }
@@ -0,0 +1,10 @@
1
+ /** A random key for a send you will not retry yourself. Prefer `deriveIdempotencyKey` so your own retries are safe too. */
2
+ export function newIdempotencyKey() {
3
+ return `sdk-${globalThis.crypto.randomUUID()}`;
4
+ }
5
+ /** A stable key derived from parts you choose, for example an order id and an event name. Same input, same key. */
6
+ export async function deriveIdempotencyKey(...parts) {
7
+ const data = new TextEncoder().encode(parts.map(String).join('\u001f'));
8
+ const hash = await globalThis.crypto.subtle.digest('SHA-256', data);
9
+ return `d-${Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 48)}`;
10
+ }
@@ -0,0 +1,7 @@
1
+ import { CommunicationError } from '../errors.js';
2
+ const serverOnly = () => {
3
+ throw new CommunicationError({ code: 'server_only', message: 'The Communication inbox client holds a secret key and must run on the server.' });
4
+ };
5
+ /** Selected by the `browser` export condition: the inbox entry never reaches client code. The component talks to the host's own routes instead. */
6
+ export const createInboxClient = serverOnly;
7
+ export const createInboxHandler = serverOnly;
@@ -0,0 +1,35 @@
1
+ import { TRANSPORT, createCommunicationClient } from '../client.js';
2
+ /** `X-Communication-Person` is base64url of the UTF-8 person id, so any identifier travels safely in a header. */
3
+ export function personHeader(personId) {
4
+ let binary = '';
5
+ for (const byte of new TextEncoder().encode(personId))
6
+ binary += String.fromCharCode(byte);
7
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
8
+ }
9
+ const isClient = (v) => TRANSPORT in v;
10
+ /**
11
+ * The in-app inbox of one person. Pass an existing client to share its key and settings, or the same options as
12
+ * `createCommunicationClient`. The key needs the scopes `inbox:read` and `inbox:write`.
13
+ */
14
+ export function createInboxClient(client = {}) {
15
+ const request = (isClient(client) ? client : createCommunicationClient(client))[TRANSPORT];
16
+ 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);
17
+ return {
18
+ summary: (personId, options) => call('GET', '/inbox/summary', personId, undefined, true, options),
19
+ list(personId, query = {}, options) {
20
+ const q = new URLSearchParams();
21
+ if (query.limit !== undefined)
22
+ q.set('limit', String(query.limit));
23
+ if (query.cursor)
24
+ q.set('cursor', query.cursor);
25
+ if (query.unreadOnly)
26
+ q.set('unread', 'true');
27
+ const qs = q.toString();
28
+ return call('GET', `/inbox/notifications${qs ? `?${qs}` : ''}`, personId, undefined, true, options);
29
+ },
30
+ markRead: (personId, id, options) => call('POST', `/inbox/notifications/${encodeURIComponent(id)}/read`, personId, {}, false, options),
31
+ markAllRead: (personId, input = {}, options) => call('POST', '/inbox/read-all', personId, input.upTo ? { upTo: input.upTo } : {}, false, options),
32
+ getPreference: (personId, options) => call('GET', '/inbox/preference', personId, undefined, true, options),
33
+ setPreference: (personId, state, options) => call('PUT', '/inbox/preference', personId, { state }, false, options),
34
+ };
35
+ }
@@ -0,0 +1,38 @@
1
+ import { CommunicationError } from '../errors.js';
2
+ /** The six answers a browser can get from the host's inbox routes. The messages are fixed: nothing from upstream is repeated. */
3
+ export const HOST_ERRORS = {
4
+ not_signed_in: { status: 401, message: 'Sign in to see your notifications.' },
5
+ not_permitted: { status: 403, message: 'This request is not allowed.' },
6
+ not_found: { status: 404, message: 'This notification is not available.' },
7
+ invalid_input: { status: 422, message: 'The request is not valid.' },
8
+ rate_limited: { status: 429, message: 'Too many requests. Try again in a moment.' },
9
+ unavailable: { status: 503, message: 'Notifications are not available right now.' },
10
+ };
11
+ /**
12
+ * Total: every failure is one of the six codes. A revoked or misconfigured key, a timeout and anything unknown are
13
+ * `unavailable`, because the person in the browser cannot fix them and must not be told about them.
14
+ */
15
+ export function hostCodeOf(error) {
16
+ if (!(error instanceof CommunicationError))
17
+ return 'unavailable';
18
+ switch (error.code) {
19
+ case 'forbidden':
20
+ return 'not_permitted';
21
+ case 'not_found':
22
+ return 'not_found';
23
+ case 'validation_failed':
24
+ return 'invalid_input';
25
+ case 'rate_limited':
26
+ return 'rate_limited';
27
+ default:
28
+ return 'unavailable';
29
+ }
30
+ }
31
+ const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' };
32
+ export function hostError(code, field) {
33
+ const body = { error: { code, message: HOST_ERRORS[code].message, ...(field ? { field } : {}) } };
34
+ return new Response(JSON.stringify(body), { status: HOST_ERRORS[code].status, headers: HEADERS });
35
+ }
36
+ export function hostData(data) {
37
+ return new Response(JSON.stringify({ data }), { status: 200, headers: HEADERS });
38
+ }
@@ -0,0 +1,124 @@
1
+ import { createInboxClient } from './client.js';
2
+ import { hostCodeOf, hostData, hostError } from './errors.js';
3
+ import { originAllowed } from './origin.js';
4
+ import { projectMarkRead, projectPage, projectPreference, projectReadAll, projectSummary } from './project.js';
5
+ import { INBOX_LIMITS, parseBody, parseCursor, parseId, parseLimit, parseState, parseUnread, parseUpTo } from './validate.js';
6
+ /** The six operations of IB-8, and nothing else. */
7
+ function match(method, pathname, base) {
8
+ if (pathname !== base && !pathname.startsWith(`${base}/`))
9
+ return null;
10
+ const rest = pathname.slice(base.length).replace(/\/+$/, '');
11
+ if (method === 'GET' && rest === '/summary')
12
+ return { name: 'summary', write: false };
13
+ if (method === 'GET' && rest === '/notifications')
14
+ return { name: 'list', write: false };
15
+ if (method === 'GET' && rest === '/preference')
16
+ return { name: 'getPreference', write: false };
17
+ if (method === 'PUT' && rest === '/preference')
18
+ return { name: 'setPreference', write: true };
19
+ if (method === 'POST' && rest === '/read-all')
20
+ return { name: 'readAll', write: true };
21
+ const read = method === 'POST' ? /^\/notifications\/([^/]+)\/read$/.exec(rest) : null;
22
+ if (read)
23
+ return { name: 'markRead', write: true, id: read[1] };
24
+ return null;
25
+ }
26
+ /**
27
+ * The host xApp's browser-facing inbox routes, in one tested function. Mount it on a catch-all route:
28
+ *
29
+ * const handler = createInboxHandler({ resolvePerson });
30
+ * export { handler as GET, handler as POST, handler as PUT };
31
+ *
32
+ * The person comes only from `resolvePerson`. The host stores nothing. Without `client`, the key and the address are
33
+ * read on the first request, so a missing one answers `unavailable` and never stops the host's build.
34
+ */
35
+ export function createInboxHandler(options) {
36
+ const base = (options.basePath ?? '/api/communication/inbox').replace(/\/+$/, '');
37
+ const call = { retry: false, timeoutMs: options.timeoutMs ?? 8000 };
38
+ let made;
39
+ const clientOf = () => options.client ?? (made ??= createInboxClient());
40
+ return async function inboxHandler(request) {
41
+ try {
42
+ const url = new URL(request.url);
43
+ const op = match(request.method.toUpperCase(), url.pathname, base);
44
+ if (!op)
45
+ return hostError('not_found');
46
+ if (op.write && !originAllowed(request, options.allowedOrigins))
47
+ return hostError('not_permitted');
48
+ const person = await options.resolvePerson(request);
49
+ if (typeof person !== 'string' || person.length === 0 || person.length > INBOX_LIMITS.personMax)
50
+ return hostError('not_signed_in');
51
+ const client = clientOf();
52
+ switch (op.name) {
53
+ case 'summary':
54
+ return answer(projectSummary(await client.summary(person, call)));
55
+ case 'list': {
56
+ const limit = parseLimit(url.searchParams.get('limit'));
57
+ if (!limit.ok)
58
+ return hostError('invalid_input', limit.field);
59
+ const cursor = parseCursor(url.searchParams.get('cursor'));
60
+ if (!cursor.ok)
61
+ return hostError('invalid_input', cursor.field);
62
+ const unread = parseUnread(url.searchParams.get('unread'));
63
+ if (!unread.ok)
64
+ return hostError('invalid_input', unread.field);
65
+ return answer(projectPage(await client.list(person, { limit: limit.value, cursor: cursor.value, unreadOnly: unread.value }, call)));
66
+ }
67
+ case 'markRead': {
68
+ let raw;
69
+ try {
70
+ raw = decodeURIComponent(op.id);
71
+ }
72
+ catch {
73
+ return hostError('invalid_input', 'id');
74
+ }
75
+ const id = parseId(raw);
76
+ if (!id.ok)
77
+ return hostError('invalid_input', id.field);
78
+ return answer(projectMarkRead(await client.markRead(person, id.value, call)));
79
+ }
80
+ case 'readAll': {
81
+ const body = parseBody(await request.text());
82
+ if (!body.ok)
83
+ return hostError('invalid_input', body.field);
84
+ const upTo = parseUpTo(body.value.upTo);
85
+ if (!upTo.ok)
86
+ return hostError('invalid_input', upTo.field);
87
+ return answer(projectReadAll(await client.markAllRead(person, { upTo: upTo.value }, call)));
88
+ }
89
+ case 'getPreference':
90
+ return answer(projectPreference(await client.getPreference(person, call)));
91
+ case 'setPreference': {
92
+ const body = parseBody(await request.text());
93
+ if (!body.ok)
94
+ return hostError('invalid_input', body.field);
95
+ const state = parseState(body.value.state);
96
+ if (!state.ok)
97
+ return hostError('invalid_input', state.field);
98
+ return answer(projectPreference(await client.setPreference(person, state.value, call)));
99
+ }
100
+ }
101
+ }
102
+ catch (error) {
103
+ const code = hostCodeOf(error);
104
+ if (code === 'unavailable')
105
+ report(error);
106
+ return hostError(code);
107
+ }
108
+ };
109
+ function answer(data) {
110
+ if (data === null) {
111
+ report(new Error('The inbox answer did not have the documented shape'));
112
+ return hostError('unavailable');
113
+ }
114
+ return hostData(data);
115
+ }
116
+ function report(error) {
117
+ try {
118
+ options.onError?.(error);
119
+ }
120
+ catch {
121
+ // A failing logger must not change the answer.
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,5 @@
1
+ export { createInboxClient, personHeader } from './client.js';
2
+ export { createInboxHandler } from './handler.js';
3
+ export { HOST_ERRORS, hostCodeOf } from './errors.js';
4
+ export { originAllowed } from './origin.js';
5
+ export { INBOX_LIMITS } from './validate.js';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A request that changes something must come from the host's own pages (14.5): the browser says `Sec-Fetch-Site: same-origin`,
3
+ * or the `Origin` header equals an allowed origin. Without `allowedOrigins`, the only allowed origin is the request's own.
4
+ */
5
+ export function originAllowed(request, allowedOrigins) {
6
+ if (request.headers.get('sec-fetch-site') === 'same-origin')
7
+ return true;
8
+ const origin = request.headers.get('origin');
9
+ if (!origin)
10
+ return false;
11
+ let allowed;
12
+ try {
13
+ allowed = allowedOrigins && allowedOrigins.length > 0 ? allowedOrigins : [new URL(request.url).origin];
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ return allowed.includes(origin);
19
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Allowlist projection: only the documented fields of IB-2 leave the host, so nothing the Gateway adds later reaches a
3
+ * browser by accident. A value of the wrong shape gives `null`, which the handler answers as `unavailable`.
4
+ */
5
+ const obj = (v) => (typeof v === 'object' && v !== null && !Array.isArray(v) ? v : null);
6
+ const str = (v) => (typeof v === 'string' ? v : null);
7
+ const strOrNull = (v) => (v === null ? null : typeof v === 'string' ? v : undefined);
8
+ const count = (v) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null);
9
+ export function projectNotification(v) {
10
+ const o = obj(v);
11
+ const source = obj(o?.source);
12
+ if (!o || !source)
13
+ return null;
14
+ const id = str(o.id);
15
+ const title = str(o.title);
16
+ const body = str(o.body);
17
+ const createdAt = str(o.createdAt);
18
+ const link = strOrNull(o.link);
19
+ const readAt = strOrNull(o.readAt);
20
+ const name = str(source.name);
21
+ if (id === null || title === null || body === null || createdAt === null || link === undefined || readAt === undefined || name === null)
22
+ return null;
23
+ if (o.kind !== 'notification' && o.kind !== 'announcement')
24
+ return null;
25
+ if (source.type !== 'xapp' && source.type !== 'tenant')
26
+ return null;
27
+ if (typeof o.read !== 'boolean')
28
+ return null;
29
+ return { id, kind: o.kind, title, body, link, source: { type: source.type, name }, createdAt, read: o.read, readAt };
30
+ }
31
+ export function projectSummary(v) {
32
+ const o = obj(v);
33
+ if (!o)
34
+ return null;
35
+ const unreadCount = count(o.unreadCount);
36
+ const newestUnreadAt = strOrNull(o.newestUnreadAt);
37
+ const pollSeconds = count(o.pollSeconds);
38
+ return unreadCount === null || newestUnreadAt === undefined || pollSeconds === null ? null : { unreadCount, newestUnreadAt, pollSeconds };
39
+ }
40
+ export function projectPage(v) {
41
+ const o = obj(v);
42
+ if (!o || !Array.isArray(o.notifications))
43
+ return null;
44
+ const notifications = [];
45
+ for (const raw of o.notifications) {
46
+ const n = projectNotification(raw);
47
+ if (!n)
48
+ return null;
49
+ notifications.push(n);
50
+ }
51
+ const nextCursor = strOrNull(o.nextCursor);
52
+ const unreadCount = count(o.unreadCount);
53
+ return nextCursor === undefined || unreadCount === null ? null : { notifications, nextCursor, unreadCount };
54
+ }
55
+ export function projectMarkRead(v) {
56
+ const o = obj(v);
57
+ const notification = projectNotification(o?.notification);
58
+ const unreadCount = count(o?.unreadCount);
59
+ return notification && unreadCount !== null ? { notification, unreadCount } : null;
60
+ }
61
+ export function projectReadAll(v) {
62
+ const o = obj(v);
63
+ const marked = count(o?.marked);
64
+ const remaining = count(o?.remaining);
65
+ const unreadCount = count(o?.unreadCount);
66
+ return marked === null || remaining === null || unreadCount === null ? null : { marked, remaining, unreadCount };
67
+ }
68
+ export function projectPreference(v) {
69
+ const o = obj(v);
70
+ if (!o)
71
+ return null;
72
+ const changedAt = strOrNull(o.changedAt);
73
+ if (o.state !== 'opted_in' && o.state !== 'opted_out' && o.state !== 'not_set')
74
+ return null;
75
+ return changedAt === undefined || typeof o.delivers !== 'boolean' ? null : { state: o.state, changedAt, delivers: o.delivers };
76
+ }
@@ -0,0 +1,49 @@
1
+ export const INBOX_LIMITS = { pageMin: 1, pageMax: 100, cursorMax: 128, idMax: 64, upToMax: 40, bodyBytes: 1024, personMax: 128 };
2
+ const ok = (value) => ({ ok: true, value });
3
+ const bad = (field) => ({ ok: false, field });
4
+ export function parseLimit(raw) {
5
+ if (raw === null)
6
+ return ok(undefined);
7
+ if (!/^\d{1,3}$/.test(raw))
8
+ return bad('limit');
9
+ const n = Number(raw);
10
+ return n >= INBOX_LIMITS.pageMin && n <= INBOX_LIMITS.pageMax ? ok(n) : bad('limit');
11
+ }
12
+ export function parseCursor(raw) {
13
+ if (raw === null)
14
+ return ok(undefined);
15
+ return raw.length > 0 && raw.length <= INBOX_LIMITS.cursorMax && /^[A-Za-z0-9_-]+$/.test(raw) ? ok(raw) : bad('cursor');
16
+ }
17
+ export function parseUnread(raw) {
18
+ if (raw === null || raw === 'false')
19
+ return ok(false);
20
+ return raw === 'true' ? ok(true) : bad('unread');
21
+ }
22
+ export function parseId(raw) {
23
+ return raw.length > 0 && raw.length <= INBOX_LIMITS.idMax && /^[A-Za-z0-9_-]+$/.test(raw) ? ok(raw) : bad('id');
24
+ }
25
+ export function parseUpTo(raw) {
26
+ if (raw === undefined || raw === null)
27
+ return ok(undefined);
28
+ if (typeof raw !== 'string' || raw.length > INBOX_LIMITS.upToMax || !/^\d{4}-\d{2}-\d{2}T[\d:.]+(Z|[+-]\d{2}:\d{2})$/.test(raw))
29
+ return bad('upTo');
30
+ const t = Date.parse(raw);
31
+ return Number.isFinite(t) ? ok(new Date(t).toISOString()) : bad('upTo');
32
+ }
33
+ export function parseState(raw) {
34
+ return raw === 'opted_in' || raw === 'opted_out' ? ok(raw) : bad('state');
35
+ }
36
+ /** A JSON object of at most 1024 bytes. An empty body is an empty object. */
37
+ export function parseBody(text) {
38
+ if (new TextEncoder().encode(text).length > INBOX_LIMITS.bodyBytes)
39
+ return bad('body');
40
+ if (text.trim() === '')
41
+ return ok({});
42
+ try {
43
+ const v = JSON.parse(text);
44
+ return typeof v === 'object' && v !== null && !Array.isArray(v) ? ok(v) : bad('body');
45
+ }
46
+ catch {
47
+ return bad('body');
48
+ }
49
+ }
@@ -0,0 +1,64 @@
1
+ /** Fills `{name}` placeholders. Values are text, never markup. */
2
+ export function fill(template, values) {
3
+ return template.replace(/\{(\w+)\}/g, (whole, key) => (key in values ? String(values[key]) : whole));
4
+ }
5
+ /** Nothing for 0, the number for 1 to 99, "99+" above (12.1). */
6
+ export function badgeText(count) {
7
+ if (!Number.isFinite(count) || count < 1)
8
+ return '';
9
+ return count > 99 ? '99+' : String(Math.floor(count));
10
+ }
11
+ /** The bell's accessible name: "{label}, {n} unread", or the label alone when nothing is unread (15.3). */
12
+ export function bellName(labels, count) {
13
+ return count > 0 ? fill(labels.bellWithCount, { label: labels.bell, n: count }) : labels.bell;
14
+ }
15
+ const LINK_MAX = 2048;
16
+ /** Accepts exactly what the inbox stores: an absolute https URL without credentials, at most 2048 characters (12.15). */
17
+ export function safeLink(link) {
18
+ if (!link || link.length > LINK_MAX)
19
+ return null;
20
+ try {
21
+ const u = new URL(link);
22
+ return u.protocol === 'https:' && !u.username && !u.password ? u.toString() : null;
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ const MINUTE = 60;
29
+ const HOUR = 3600;
30
+ const DAY = 86400;
31
+ /** "now", "5 minutes ago", "yesterday", then a short date after a week. A time in the future (clock skew) reads as now. */
32
+ export function relativeTime(iso, now, locale) {
33
+ const then = Date.parse(iso);
34
+ if (!Number.isFinite(then))
35
+ return '';
36
+ const seconds = Math.max(0, Math.floor((now.getTime() - then) / 1000));
37
+ try {
38
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
39
+ if (seconds < MINUTE)
40
+ return rtf.format(0, 'second');
41
+ if (seconds < HOUR)
42
+ return rtf.format(-Math.floor(seconds / MINUTE), 'minute');
43
+ if (seconds < DAY)
44
+ return rtf.format(-Math.floor(seconds / HOUR), 'hour');
45
+ if (seconds < 7 * DAY)
46
+ return rtf.format(-Math.floor(seconds / DAY), 'day');
47
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(then));
48
+ }
49
+ catch {
50
+ return new Date(then).toISOString().slice(0, 10);
51
+ }
52
+ }
53
+ /** The full date and time, for the `title` of a relative time. */
54
+ export function exactTime(iso, locale) {
55
+ const then = Date.parse(iso);
56
+ if (!Number.isFinite(then))
57
+ return '';
58
+ try {
59
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeStyle: 'short' }).format(new Date(then));
60
+ }
61
+ catch {
62
+ return new Date(then).toISOString();
63
+ }
64
+ }