@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,88 @@
1
+ import { useEffect, useRef } from 'react';
2
+ export const POLL_FLOOR_SECONDS = 15;
3
+ export const POLL_CAP_SECONDS = 300;
4
+ /**
5
+ * Seconds until the next poll. After a success: the recommended interval, at least 15. After failures:
6
+ * `min(300, base * 2^failures)` with plus or minus 20 percent jitter, still between 15 and 300.
7
+ */
8
+ export function nextPollDelay(failures, recommendedSeconds, random = Math.random) {
9
+ const base = Math.max(POLL_FLOOR_SECONDS, Number.isFinite(recommendedSeconds) ? recommendedSeconds : 60);
10
+ if (failures <= 0)
11
+ return base;
12
+ const backoff = Math.min(POLL_CAP_SECONDS, base * 2 ** Math.min(failures, 16));
13
+ const jitter = 0.8 + 0.4 * Math.min(1, Math.max(0, random()));
14
+ return Math.min(POLL_CAP_SECONDS, Math.max(POLL_FLOOR_SECONDS, backoff * jitter));
15
+ }
16
+ /**
17
+ * Polls now, then after each answer waits `nextPollDelay`. Pauses while the page is hidden and polls at once when it
18
+ * is visible again. `stop` (nobody is signed in) ends polling silently. Returns a function that stops it.
19
+ */
20
+ export function startPolling(poll, env) {
21
+ let stopped = false;
22
+ let running = false;
23
+ let failures = 0;
24
+ let recommended = 60;
25
+ let timer = null;
26
+ const tick = async () => {
27
+ if (stopped || running || !env.isVisible())
28
+ return;
29
+ running = true;
30
+ let outcome;
31
+ try {
32
+ outcome = await poll();
33
+ }
34
+ catch {
35
+ outcome = { result: 'failed' };
36
+ }
37
+ running = false;
38
+ if (stopped)
39
+ return;
40
+ if (outcome.result === 'stop') {
41
+ stopped = true;
42
+ return;
43
+ }
44
+ if (outcome.result === 'ok') {
45
+ failures = 0;
46
+ recommended = outcome.pollSeconds;
47
+ }
48
+ else
49
+ failures += 1;
50
+ timer = env.setTimer(() => void tick(), nextPollDelay(failures, recommended, env.random) * 1000);
51
+ };
52
+ const unsubscribe = env.onVisibilityChange(() => {
53
+ if (stopped)
54
+ return;
55
+ env.clearTimer(timer);
56
+ timer = null;
57
+ if (env.isVisible())
58
+ void tick();
59
+ });
60
+ void tick();
61
+ return () => {
62
+ stopped = true;
63
+ env.clearTimer(timer);
64
+ unsubscribe();
65
+ };
66
+ }
67
+ const browserEnvironment = () => ({
68
+ isVisible: () => document.visibilityState !== 'hidden',
69
+ onVisibilityChange: (listener) => {
70
+ document.addEventListener('visibilitychange', listener);
71
+ return () => document.removeEventListener('visibilitychange', listener);
72
+ },
73
+ setTimer: (fn, ms) => window.setTimeout(fn, ms),
74
+ clearTimer: (handle) => window.clearTimeout(handle),
75
+ random: Math.random,
76
+ });
77
+ /** Runs `poll` for as long as the component is mounted and `enabled` is true. `poll` may change between renders. */
78
+ export function useInboxPolling(poll, enabled = true) {
79
+ const latest = useRef(poll);
80
+ useEffect(() => {
81
+ latest.current = poll;
82
+ }, [poll]);
83
+ useEffect(() => {
84
+ if (!enabled)
85
+ return undefined;
86
+ return startPolling(() => latest.current(), browserEnvironment());
87
+ }, [enabled]);
88
+ }
@@ -0,0 +1,4 @@
1
+ export { createCommunicationClient, CommunicationError } from './client.js';
2
+ export { createEventHandler, verifyEventSignature } from './events.js';
3
+ export { deriveIdempotencyKey, newIdempotencyKey } from './idempotency.js';
4
+ export { DEFAULT_RETRY } from './retry.js';
@@ -0,0 +1,9 @@
1
+ export const DEFAULT_RETRY = { maxAttempts: 3, baseDelayMs: 250, capDelayMs: 5000, totalTimeoutMs: 25_000, perAttemptTimeoutMs: 10_000 };
2
+ /** Exponential backoff with full jitter, honouring Retry-After when the server sent one. */
3
+ export function backoffMs(attempt, policy, retryAfterSeconds, random = Math.random) {
4
+ const exp = Math.min(policy.capDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
5
+ const jittered = Math.round(exp * (0.5 + random() * 0.5));
6
+ return retryAfterSeconds ? Math.min(policy.capDelayMs, Math.max(jittered, retryAfterSeconds * 1000)) : jittered;
7
+ }
8
+ /** Only transient failures are retried: network errors, timeouts, 429 and 5xx. Client errors never are. */
9
+ export const isRetryableStatus = (status) => status === 429 || (status >= 500 && status !== 501);
@@ -0,0 +1,65 @@
1
+ /*
2
+ * The inbox's own styles. Import once in the host app, after Mantine's styles:
3
+ * import '@xtremax/communication/inbox-react/styles.css';
4
+ * Colours come from Mantine theme variables only, so the inbox follows the host app's theme and colour scheme (12.16).
5
+ */
6
+ .xci-list {
7
+ list-style: none;
8
+ margin: 0;
9
+ padding: 0;
10
+ }
11
+
12
+ .xci-entry + .xci-entry {
13
+ border-top: 1px solid var(--mantine-color-default-border);
14
+ }
15
+
16
+ /* Two classes, so these win over Mantine's own button styles whichever stylesheet loads first. */
17
+ .xci-list .xci-row {
18
+ display: flex;
19
+ align-items: flex-start;
20
+ gap: var(--mantine-spacing-sm);
21
+ width: 100%;
22
+ min-height: 44px;
23
+ padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
24
+ text-align: start;
25
+ color: var(--mantine-color-text);
26
+ }
27
+
28
+ @media (hover: hover) {
29
+ .xci-list .xci-row:hover {
30
+ background-color: var(--mantine-color-default-hover);
31
+ }
32
+ }
33
+
34
+ .xci-dot {
35
+ flex: none;
36
+ width: 8px;
37
+ height: 8px;
38
+ margin-top: 6px;
39
+ border-radius: 50%;
40
+ background-color: transparent;
41
+ }
42
+
43
+ .xci-dot[data-unread] {
44
+ background-color: var(--mantine-primary-color-filled);
45
+ }
46
+
47
+ .xci-content {
48
+ display: flex;
49
+ flex: 1;
50
+ flex-direction: column;
51
+ gap: 2px;
52
+ min-width: 0;
53
+ }
54
+
55
+ .xci-meta {
56
+ display: flex;
57
+ align-items: center;
58
+ gap: var(--mantine-spacing-xs);
59
+ min-width: 0;
60
+ }
61
+
62
+ .xci-line {
63
+ display: block;
64
+ overflow-wrap: anywhere;
65
+ }
@@ -0,0 +1,4 @@
1
+ import { CommunicationError } from './errors.js';
2
+ /** Selected by the `browser` export condition so a secret key can never be bundled into client code. */
3
+ export declare function createCommunicationClient(): never;
4
+ export { CommunicationError };
@@ -0,0 +1,94 @@
1
+ import type { AcceptedMessage, ChannelInfo, MessageView, PingInfo, RecipientView, SendMessageRequest } from './contract.js';
2
+ import { type RetryPolicy } from './retry.js';
3
+ export interface ClientOptions {
4
+ /** Defaults to `process.env.COMMUNICATION_API_KEY`. */
5
+ apiKey?: string;
6
+ /** Defaults to `process.env.COMMUNICATION_URL`. */
7
+ baseUrl?: string;
8
+ fetch?: typeof fetch;
9
+ retry?: Partial<RetryPolicy>;
10
+ /** Injectable for tests. */
11
+ sleep?: (ms: number) => Promise<void>;
12
+ random?: () => number;
13
+ }
14
+ /** Per-call choices. `retry: false` makes one attempt; `timeoutMs` bounds the whole call; `headers` never replace the key. */
15
+ export interface RequestOptions {
16
+ retry?: boolean;
17
+ headers?: Record<string, string>;
18
+ timeoutMs?: number;
19
+ }
20
+ export type Transport = <T>(method: string, path: string, body?: unknown, opts?: RequestOptions) => Promise<{
21
+ data: T;
22
+ headers: Headers;
23
+ }>;
24
+ /** Where a client keeps its transport, so `@xtremax/pigeon/inbox` can reuse the key handling, retries and errors. */
25
+ export declare const TRANSPORT: unique symbol;
26
+ export declare function createCommunicationClient(options?: ClientOptions): {
27
+ messages: {
28
+ /** Sends are retried safely because every request carries an idempotency key (yours, or a random one). */
29
+ send(req: SendMessageRequest): Promise<AcceptedMessage>;
30
+ get: (id: string) => Promise<MessageView>;
31
+ };
32
+ channels: {
33
+ list: () => Promise<ChannelInfo[]>;
34
+ };
35
+ ping: () => Promise<PingInfo>;
36
+ recipients: {
37
+ put: (personId: string, body: {
38
+ timezone?: string | null;
39
+ locale?: string | null;
40
+ quietHours?: {
41
+ start: string;
42
+ end: string;
43
+ } | null;
44
+ }) => Promise<RecipientView>;
45
+ get: (personId: string) => Promise<RecipientView>;
46
+ delete(personId: string): Promise<void>;
47
+ putAddress: (personId: string, body: {
48
+ medium: "email" | "whatsapp";
49
+ address: string;
50
+ primary?: boolean;
51
+ verified?: boolean;
52
+ }) => Promise<{
53
+ id: string;
54
+ masked: string;
55
+ }>;
56
+ deleteAddress(personId: string, addressId: string): Promise<void>;
57
+ };
58
+ preferences: {
59
+ record: (body: {
60
+ recipient: {
61
+ personId?: string;
62
+ address?: string;
63
+ medium?: "email" | "whatsapp";
64
+ };
65
+ medium: "email" | "whatsapp" | "in_app";
66
+ state: "opted_in" | "opted_out";
67
+ expressedAt?: string;
68
+ evidence?: {
69
+ method?: string;
70
+ wordingVersion?: string;
71
+ proofRef?: string;
72
+ };
73
+ }) => Promise<{
74
+ changed: boolean;
75
+ current: unknown;
76
+ }>;
77
+ };
78
+ messageClasses: {
79
+ put: (templateKey: string, body: {
80
+ class: "transactional" | "non_transactional";
81
+ note?: string;
82
+ }) => Promise<unknown>;
83
+ list: () => Promise<{
84
+ templateKey: string;
85
+ class: string;
86
+ note: string | null;
87
+ }[]>;
88
+ };
89
+ /** For logs: never prints the key. */
90
+ describe: () => string;
91
+ [TRANSPORT]: Transport;
92
+ };
93
+ export type CommunicationClient = ReturnType<typeof createCommunicationClient>;
94
+ export { CommunicationError } from './errors.js';
@@ -0,0 +1,182 @@
1
+ /** Types mirrored from the Gateway's external API (types only; safe in any bundle). */
2
+ export declare const SUPPORTED_API_VERSION = "v1";
3
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
4
+ [key: string]: JsonValue;
5
+ };
6
+ export type MessageState = 'queued' | 'sent' | 'delivered' | 'read' | 'failed';
7
+ export type FailureCategory = 'invalid_recipient' | 'recipient_unreachable' | 'recipient_blocked' | 'content_rejected' | 'provider_rejected' | 'provider_unavailable' | 'rate_limited' | 'configuration_error' | 'policy_refused' | 'retries_exhausted' | 'unknown';
8
+ export interface NeutralReason {
9
+ category: FailureCategory;
10
+ code: string;
11
+ message: string;
12
+ }
13
+ export type ApiErrorCode = 'invalid_json' | 'invalid_credentials' | 'forbidden' | 'not_found' | 'idempotency_conflict' | 'payload_too_large' | 'unsupported_media_type' | 'validation_failed' | 'channel_unavailable' | 'tls_required' | 'rate_limited' | 'internal_error' | 'temporarily_unavailable' | 'conflict';
14
+ export interface ApiErrorDetail {
15
+ field?: string;
16
+ code?: string;
17
+ message?: string;
18
+ reason?: string;
19
+ }
20
+ export interface ApiErrorBody {
21
+ error: {
22
+ code: string;
23
+ message: string;
24
+ requestId?: string;
25
+ details?: ApiErrorDetail[];
26
+ };
27
+ }
28
+ export interface SendMessageRequest {
29
+ templateKey: string;
30
+ channel: string;
31
+ recipient: {
32
+ personId?: string;
33
+ address?: string;
34
+ };
35
+ variables?: Record<string, JsonValue>;
36
+ /** Optional: the SDK derives a stable key from the request when you leave it out. */
37
+ idempotencyKey?: string;
38
+ reference?: JsonValue;
39
+ locale?: string;
40
+ options?: {
41
+ testOutcome?: 'accepted' | 'delivered' | 'read' | 'transient_failure' | 'permanent_failure';
42
+ succeedOnAttempt?: number;
43
+ };
44
+ }
45
+ export interface AcceptedMessage {
46
+ id: string;
47
+ state: MessageState;
48
+ channel: string;
49
+ reference: JsonValue | null;
50
+ acceptedAt: string;
51
+ idempotentReplay: boolean;
52
+ }
53
+ export interface MessageView {
54
+ id: string;
55
+ state: MessageState;
56
+ channel: string;
57
+ templateKey: string;
58
+ reference: JsonValue | null;
59
+ acceptedAt: string;
60
+ test: boolean;
61
+ failure: NeutralReason | null;
62
+ history: Array<{
63
+ sequence: number;
64
+ previousState: MessageState | null;
65
+ state: MessageState;
66
+ occurredAt: string;
67
+ cause: string;
68
+ reason: NeutralReason | null;
69
+ }>;
70
+ attempts: Array<{
71
+ attempt: number;
72
+ startedAt: string;
73
+ finishedAt: string | null;
74
+ outcome: 'in_progress' | 'accepted' | 'transient_failure' | 'permanent_failure' | 'interrupted';
75
+ reasonCode: string | null;
76
+ }>;
77
+ }
78
+ export interface ChannelInfo {
79
+ key: string;
80
+ name: string;
81
+ medium: 'email' | 'whatsapp' | 'in_app';
82
+ owner: 'shared' | 'tenant';
83
+ ready: boolean;
84
+ reason: string | null;
85
+ }
86
+ export interface PingInfo {
87
+ clientId: string;
88
+ tenantKey: string; /** The sub-tenant this key belongs to, or null at tenant level. The in-app inbox shows a person what this sub-tenant sent them. */
89
+ subKey?: string | null;
90
+ scopes: string[];
91
+ }
92
+ export interface MessageStateChangedEvent {
93
+ eventId: string;
94
+ type: 'message.state_changed';
95
+ version: 1;
96
+ sequence: number;
97
+ messageId: string;
98
+ reference: JsonValue | null;
99
+ channel: string;
100
+ previousState: MessageState;
101
+ state: MessageState;
102
+ occurredAt: string;
103
+ reason: {
104
+ category: FailureCategory;
105
+ code: string;
106
+ message: string;
107
+ } | null;
108
+ test: boolean;
109
+ }
110
+ export interface RecipientView {
111
+ personId: string;
112
+ status: 'active' | 'erased';
113
+ timezone: string | null;
114
+ locale: string | null;
115
+ quietHours: {
116
+ start: string;
117
+ end: string;
118
+ } | null;
119
+ addresses: Array<{
120
+ id: string;
121
+ medium: string;
122
+ masked: string;
123
+ primary: boolean;
124
+ verifiedAt: string | null;
125
+ }>;
126
+ preferences: Array<{
127
+ medium: string;
128
+ state: 'opted_in' | 'opted_out' | 'not_set';
129
+ changedAt: string | null;
130
+ source: string | null;
131
+ }>;
132
+ }
133
+ export interface InboxNotification {
134
+ /** The inbox item, not the message. */
135
+ id: string;
136
+ kind: 'notification' | 'announcement';
137
+ /** Plain text. */
138
+ title: string;
139
+ body: string;
140
+ /** Absolute https, or null. */
141
+ link: string | null;
142
+ source: {
143
+ type: 'xapp' | 'tenant';
144
+ name: string;
145
+ };
146
+ createdAt: string;
147
+ read: boolean;
148
+ readAt: string | null;
149
+ }
150
+ export interface InboxSummary {
151
+ unreadCount: number;
152
+ newestUnreadAt: string | null;
153
+ pollSeconds: number;
154
+ }
155
+ export interface InboxNotificationPage {
156
+ notifications: InboxNotification[];
157
+ nextCursor: string | null;
158
+ unreadCount: number;
159
+ }
160
+ export interface InboxPreference {
161
+ state: 'opted_in' | 'opted_out' | 'not_set';
162
+ changedAt: string | null;
163
+ delivers: boolean;
164
+ }
165
+ export interface InboxMarkReadResult {
166
+ notification: InboxNotification;
167
+ unreadCount: number;
168
+ }
169
+ export interface InboxReadAllResult {
170
+ marked: number;
171
+ remaining: number;
172
+ unreadCount: number;
173
+ }
174
+ /** What the host's browser-facing inbox routes answer when a call fails (IB-8). */
175
+ export type InboxHostErrorCode = 'not_signed_in' | 'not_permitted' | 'not_found' | 'invalid_input' | 'rate_limited' | 'unavailable';
176
+ export interface InboxHostErrorBody {
177
+ error: {
178
+ code: InboxHostErrorCode;
179
+ message: string;
180
+ field?: string;
181
+ };
182
+ }
@@ -0,0 +1,29 @@
1
+ import type { ApiErrorCode, ApiErrorDetail } from './contract.js';
2
+ export type ErrorCode = ApiErrorCode | 'unknown_error' | 'network_error' | 'timeout' | 'aborted' | 'invalid_response' | 'not_configured' | 'server_only';
3
+ /** Removes anything shaped like a key from text that may be logged. */
4
+ export declare const redact: (text: string) => string;
5
+ export declare class CommunicationError extends Error {
6
+ readonly code: ErrorCode;
7
+ readonly apiCode: string | null;
8
+ readonly status: number | null;
9
+ readonly requestId: string | null;
10
+ readonly details: readonly ApiErrorDetail[];
11
+ readonly retryable: boolean;
12
+ readonly retryAfterSeconds: number | null;
13
+ readonly attempts: number;
14
+ constructor(init: {
15
+ code: ErrorCode;
16
+ message: string;
17
+ apiCode?: string | null;
18
+ status?: number | null;
19
+ requestId?: string | null;
20
+ details?: readonly ApiErrorDetail[];
21
+ retryable?: boolean;
22
+ retryAfterSeconds?: number | null;
23
+ attempts?: number;
24
+ });
25
+ toJSON(): Record<string, unknown>;
26
+ }
27
+ export declare function fromResponse(status: number, body: unknown, headers: {
28
+ get(n: string): string | null;
29
+ }, attempts: number): CommunicationError;
@@ -0,0 +1,26 @@
1
+ import type { MessageStateChangedEvent } from './contract.js';
2
+ export declare const DEFAULT_TOLERANCE_SECONDS = 300;
3
+ export type VerifyResult = {
4
+ valid: true;
5
+ } | {
6
+ valid: false;
7
+ reason: 'missing' | 'mismatch' | 'stale';
8
+ };
9
+ /** 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. */
10
+ export declare function verifyEventSignature(input: {
11
+ secret: string | readonly string[];
12
+ timestamp: string | null | undefined;
13
+ signature: string | null | undefined;
14
+ rawBody: string;
15
+ nowSeconds?: number;
16
+ toleranceSeconds?: number;
17
+ }): Promise<VerifyResult>;
18
+ export interface EventHandlerOptions {
19
+ secret: string | readonly string[];
20
+ onEvent(event: MessageStateChangedEvent): void | Promise<void>;
21
+ /** Return true when this event, or a later one for the same message, was already processed. Keeps retries and reordering harmless. */
22
+ alreadyProcessed?(event: MessageStateChangedEvent): boolean | Promise<boolean>;
23
+ toleranceSeconds?: number;
24
+ }
25
+ /** A ready-made handler for a Next.js route or any Request/Response runtime. Answers 2xx only after `onEvent` succeeded. */
26
+ export declare function createEventHandler(options: EventHandlerOptions): (req: Request) => Promise<Response>;
@@ -0,0 +1,4 @@
1
+ /** A random key for a send you will not retry yourself. Prefer `deriveIdempotencyKey` so your own retries are safe too. */
2
+ export declare function newIdempotencyKey(): string;
3
+ /** A stable key derived from parts you choose, for example an order id and an event name. Same input, same key. */
4
+ export declare function deriveIdempotencyKey(...parts: Array<string | number>): Promise<string>;
@@ -0,0 +1,3 @@
1
+ /** Selected by the `browser` export condition: the inbox entry never reaches client code. The component talks to the host's own routes instead. */
2
+ export declare const createInboxClient: () => never;
3
+ export declare const createInboxHandler: () => never;
@@ -0,0 +1,34 @@
1
+ import { type ClientOptions, type CommunicationClient } from '../client.js';
2
+ import type { InboxMarkReadResult, InboxNotificationPage, InboxPreference, InboxReadAllResult, InboxSummary } from '../contract.js';
3
+ export interface CallOptions {
4
+ /** Reads are retried by the SDK's policy unless this is `false`. Writes make one attempt unless this is `true`. */
5
+ retry?: boolean;
6
+ /** Bounds the whole call, in milliseconds. */
7
+ timeoutMs?: number;
8
+ }
9
+ /**
10
+ * The in-app inbox as this key sees it: what its own sub-tenant (or the tenant level, for a key outside any) sent to
11
+ * the person, and the tenant's announcements. Another app's notifications are not part of it.
12
+ */
13
+ export interface InboxClient {
14
+ summary(personId: string, options?: CallOptions): Promise<InboxSummary>;
15
+ list(personId: string, query?: {
16
+ limit?: number;
17
+ cursor?: string;
18
+ unreadOnly?: boolean;
19
+ }, options?: CallOptions): Promise<InboxNotificationPage>;
20
+ markRead(personId: string, id: string, options?: CallOptions): Promise<InboxMarkReadResult>;
21
+ markAllRead(personId: string, input?: {
22
+ upTo?: string;
23
+ }, options?: CallOptions): Promise<InboxReadAllResult>;
24
+ /** The person's choice for this app's notifications. Another app's choice is its own. */
25
+ getPreference(personId: string, options?: CallOptions): Promise<InboxPreference>;
26
+ setPreference(personId: string, state: 'opted_in' | 'opted_out', options?: CallOptions): Promise<InboxPreference>;
27
+ }
28
+ /** `X-Communication-Person` is base64url of the UTF-8 person id, so any identifier travels safely in a header. */
29
+ export declare function personHeader(personId: string): string;
30
+ /**
31
+ * The in-app inbox of one person. Pass an existing client to share its key and settings, or the same options as
32
+ * `createCommunicationClient`. The key needs the scopes `inbox:read` and `inbox:write`.
33
+ */
34
+ export declare function createInboxClient(client?: ClientOptions | CommunicationClient): InboxClient;
@@ -0,0 +1,13 @@
1
+ import type { InboxHostErrorCode } from '../contract.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 declare const HOST_ERRORS: Readonly<Record<InboxHostErrorCode, {
4
+ status: number;
5
+ message: string;
6
+ }>>;
7
+ /**
8
+ * Total: every failure is one of the six codes. A revoked or misconfigured key, a timeout and anything unknown are
9
+ * `unavailable`, because the person in the browser cannot fix them and must not be told about them.
10
+ */
11
+ export declare function hostCodeOf(error: unknown): InboxHostErrorCode;
12
+ export declare function hostError(code: InboxHostErrorCode, field?: string): Response;
13
+ export declare function hostData(data: unknown): Response;
@@ -0,0 +1,25 @@
1
+ import { type InboxClient } from './client.js';
2
+ export interface InboxHandlerOptions {
3
+ /** Default: a client made on the first request from `COMMUNICATION_API_KEY` and `COMMUNICATION_URL`. */
4
+ client?: InboxClient;
5
+ /** The xApp's only job: who is signed in. `null` means nobody. Read it from your own session, never from the request's path, query or body. */
6
+ resolvePerson: (request: Request) => Promise<string | null> | string | null;
7
+ /** Where the routes are mounted. Default `/api/communication/inbox`. */
8
+ basePath?: string;
9
+ /** Origins that may send POST and PUT. Default: the request's own origin. */
10
+ allowedOrigins?: readonly string[];
11
+ /** Default 8000. */
12
+ timeoutMs?: number;
13
+ /** Told about every failure answered as `unavailable`, for the host's own log. The browser never sees it. */
14
+ onError?: (error: unknown) => void;
15
+ }
16
+ /**
17
+ * The host xApp's browser-facing inbox routes, in one tested function. Mount it on a catch-all route:
18
+ *
19
+ * const handler = createInboxHandler({ resolvePerson });
20
+ * export { handler as GET, handler as POST, handler as PUT };
21
+ *
22
+ * The person comes only from `resolvePerson`. The host stores nothing. Without `client`, the key and the address are
23
+ * read on the first request, so a missing one answers `unavailable` and never stops the host's build.
24
+ */
25
+ export declare function createInboxHandler(options: InboxHandlerOptions): (request: Request) => Promise<Response>;
@@ -0,0 +1,6 @@
1
+ export { createInboxClient, personHeader, type CallOptions, type InboxClient } from './client.js';
2
+ export { createInboxHandler, type InboxHandlerOptions } from './handler.js';
3
+ export { HOST_ERRORS, hostCodeOf } from './errors.js';
4
+ export { originAllowed } from './origin.js';
5
+ export { INBOX_LIMITS } from './validate.js';
6
+ export type { InboxHostErrorBody, InboxHostErrorCode, InboxMarkReadResult, InboxNotification, InboxNotificationPage, InboxPreference, InboxReadAllResult, InboxSummary } from '../contract.js';
@@ -0,0 +1,5 @@
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 declare function originAllowed(request: Request, allowedOrigins?: readonly string[]): boolean;
@@ -0,0 +1,7 @@
1
+ import type { InboxMarkReadResult, InboxNotification, InboxNotificationPage, InboxPreference, InboxReadAllResult, InboxSummary } from '../contract.js';
2
+ export declare function projectNotification(v: unknown): InboxNotification | null;
3
+ export declare function projectSummary(v: unknown): InboxSummary | null;
4
+ export declare function projectPage(v: unknown): InboxNotificationPage | null;
5
+ export declare function projectMarkRead(v: unknown): InboxMarkReadResult | null;
6
+ export declare function projectReadAll(v: unknown): InboxReadAllResult | null;
7
+ export declare function projectPreference(v: unknown): InboxPreference | null;
@@ -0,0 +1,25 @@
1
+ /** Bounded parsing of every value that comes from a browser. A value is either refused, naming its field, or inside the documented bounds. */
2
+ export type Parsed<T> = {
3
+ ok: true;
4
+ value: T;
5
+ } | {
6
+ ok: false;
7
+ field: string;
8
+ };
9
+ export declare const INBOX_LIMITS: {
10
+ readonly pageMin: 1;
11
+ readonly pageMax: 100;
12
+ readonly cursorMax: 128;
13
+ readonly idMax: 64;
14
+ readonly upToMax: 40;
15
+ readonly bodyBytes: 1024;
16
+ readonly personMax: 128;
17
+ };
18
+ export declare function parseLimit(raw: string | null): Parsed<number | undefined>;
19
+ export declare function parseCursor(raw: string | null): Parsed<string | undefined>;
20
+ export declare function parseUnread(raw: string | null): Parsed<boolean>;
21
+ export declare function parseId(raw: string): Parsed<string>;
22
+ export declare function parseUpTo(raw: unknown): Parsed<string | undefined>;
23
+ export declare function parseState(raw: unknown): Parsed<'opted_in' | 'opted_out'>;
24
+ /** A JSON object of at most 1024 bytes. An empty body is an empty object. */
25
+ export declare function parseBody(text: string): Parsed<Record<string, unknown>>;
@@ -0,0 +1,13 @@
1
+ import type { InboxLabels } from './labels.js';
2
+ /** Fills `{name}` placeholders. Values are text, never markup. */
3
+ export declare function fill(template: string, values: Record<string, string | number>): string;
4
+ /** Nothing for 0, the number for 1 to 99, "99+" above (12.1). */
5
+ export declare function badgeText(count: number): string;
6
+ /** The bell's accessible name: "{label}, {n} unread", or the label alone when nothing is unread (15.3). */
7
+ export declare function bellName(labels: Pick<InboxLabels, 'bell' | 'bellWithCount'>, count: number): string;
8
+ /** Accepts exactly what the inbox stores: an absolute https URL without credentials, at most 2048 characters (12.15). */
9
+ export declare function safeLink(link: string | null | undefined): string | null;
10
+ /** "now", "5 minutes ago", "yesterday", then a short date after a week. A time in the future (clock skew) reads as now. */
11
+ export declare function relativeTime(iso: string, now: Date, locale?: string): string;
12
+ /** The full date and time, for the `title` of a relative time. */
13
+ export declare function exactTime(iso: string, locale?: string): string;