@hermesihq/react 0.1.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.
@@ -0,0 +1,444 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * Shared, framework-agnostic shapes for `HermsClient` and every React binding
6
+ * built on top of it. Camel-cased on purpose. The wire (§6.6 `/v1/client/inbox/*`)
7
+ * is snake_case; `HermsClient` is the one place that translation happens, the same
8
+ * posture the dashboard's own hand-written API layer takes (`frontend/src/api/*.ts`)
9
+ * until generated OpenAPI types exist for either surface.
10
+ */
11
+ interface HermsClientOptions {
12
+ /** `hm_pk_<env>_<random>`, safe to embed in a browser bundle (§6.2: "grant
13
+ * nothing by themselves"). */
14
+ publicKey: string;
15
+ /** Base URL of the client API, e.g. `https://api.hermesi.dev/v1/client` or,
16
+ * same-origin behind a dev proxy, `/v1/client`. No trailing slash required:
17
+ * normalised internally. */
18
+ apiBaseUrl: string;
19
+ /**
20
+ * Host-app-supplied. This SDK never has the secret key and never could
21
+ * (F-INB-2: a subscriber token is minted server-side, by the host's own
22
+ * backend). Called fresh before *every* request; never cached past what the
23
+ * host itself returns, since refreshing ahead of expiry is explicitly the
24
+ * host's job, not this client's (see `onTokenExpiring`).
25
+ */
26
+ getSubscriberToken: () => string | Promise<string>;
27
+ /**
28
+ * Called proactively, once, some seconds before the *currently held* token's
29
+ * own `exp` claim (read back out of the token's payload segment client-side:
30
+ * it's HMAC-signed, not encrypted, so this never requires verifying the
31
+ * signature). This is a hook for the host app to refresh ahead of expiry rather
32
+ * than reactively after a `401`. Best-effort: a token whose payload can't be
33
+ * decoded (malformed, or a host app testing with a garbage string) simply
34
+ * never schedules a call.
35
+ */
36
+ onTokenExpiring?: () => void;
37
+ }
38
+ type HermsInboxReadStatus = 'read' | 'unread';
39
+ interface HermsInboxCategory {
40
+ key: string;
41
+ name: string;
42
+ }
43
+ interface HermsInboxItem {
44
+ id: string;
45
+ title: string;
46
+ body: string;
47
+ actionUrl: string | null;
48
+ category: HermsInboxCategory | null;
49
+ seenAt: string | null;
50
+ readAt: string | null;
51
+ createdAt: string;
52
+ }
53
+ interface HermsInboxCounts {
54
+ unread: number;
55
+ unseen: number;
56
+ }
57
+ interface HermsInboxPage {
58
+ items: HermsInboxItem[];
59
+ hasMore: boolean;
60
+ nextCursor: string | null;
61
+ }
62
+ interface HermsInboxListParams {
63
+ /** Maps to `GET /inbox?read=<bool>`: `'read'` -> `read=true`, `'unread'` ->
64
+ * `read=false`, omitted -> both. */
65
+ status?: HermsInboxReadStatus;
66
+ /** Category key(s), `?category=` (repeatable server-side). */
67
+ category?: string | string[];
68
+ cursor?: string;
69
+ limit?: number;
70
+ }
71
+ /**
72
+ * Every channel Hermesi can hold an identity for.
73
+ *
74
+ * **Written out, and checked against the API by a test rather than trusted.** This
75
+ * union said `'push' | 'sms' | 'email' | 'in_app'` while the platform had grown to
76
+ * nine, so an integrator could not register a Telegram or Slack identity at all:
77
+ * TypeScript refused it at their call site, and no test here or in the dashboard had
78
+ * any reason to notice, because the demo page never registers a channel.
79
+ *
80
+ * It cannot be generated: this package is published standalone and must not depend on
81
+ * the repository's OpenAPI snapshot at build time. So `channelParity.test.ts` compares
82
+ * it to that snapshot's `Channel` enum instead. The list stays hand-written, and
83
+ * drifting from the server is what fails.
84
+ *
85
+ * `push` is included because the API accepts it. No adapter delivers it today, which is
86
+ * a deployment fact rather than a reason to refuse the identity.
87
+ *
88
+ * Exported as a value, not only a type: a host building a preference centre needs to
89
+ * iterate the channels at runtime, and deriving the type from the array means the two
90
+ * cannot disagree.
91
+ */
92
+ declare const HERMS_CHANNELS: readonly ["in_app", "email", "push", "sms", "whatsapp", "telegram", "slack", "teams", "discord"];
93
+ type HermsChannel = (typeof HERMS_CHANNELS)[number];
94
+ interface HermsRegisterChannelParams {
95
+ channel: HermsChannel;
96
+ identifier: string;
97
+ metadata?: Record<string, unknown>;
98
+ }
99
+ /** One channel's setting, either globally or inside a category. `null` means the
100
+ * subscriber has expressed no preference and the category's or platform's default
101
+ * applies. It is not the same as `false`, and collapsing the two would silently
102
+ * opt somebody out of something they never declined. */
103
+ interface HermsChannelPreference {
104
+ channel: HermsChannel;
105
+ enabled: boolean | null;
106
+ }
107
+ interface HermsCategoryPreference {
108
+ categoryId: string;
109
+ key: string;
110
+ name: string;
111
+ /** A critical category is always delivered on every live channel and cannot be turned
112
+ * off. Render it, but not as a control: an affordance that refuses is worse than
113
+ * none. */
114
+ isCritical: boolean;
115
+ channels: HermsChannelPreference[];
116
+ emailEnabled: boolean | null;
117
+ inAppEnabled: boolean | null;
118
+ }
119
+ interface HermsPreferences {
120
+ /** For addressing the subscriber in a preference page's own copy. `null` when the
121
+ * application never supplied one. */
122
+ firstName: string | null;
123
+ /** Settings that apply across every category. */
124
+ globalChannels: HermsChannelPreference[];
125
+ /** `email` and `in_app` again, as scalars.
126
+ *
127
+ * Carried rather than dropped because the API publishes them, and dropping a
128
+ * published field is the defect `HermsApiError` had. They are the same values as
129
+ * the matching entries in `globalChannels`; read whichever suits, but do not treat
130
+ * a disagreement between them as meaningful. */
131
+ globalEmailEnabled: boolean | null;
132
+ globalInAppEnabled: boolean | null;
133
+ categories: HermsCategoryPreference[];
134
+ }
135
+ /** One setting to change. Omit `categoryId` to change the global setting for that
136
+ * channel; supply it to change the setting inside one category. */
137
+ interface HermsPreferenceUpdate {
138
+ channel: HermsChannel;
139
+ enabled: boolean;
140
+ categoryId?: string;
141
+ }
142
+ interface HermsItemCreatedEvent {
143
+ type: 'item.created';
144
+ data: {
145
+ id: string;
146
+ title: string;
147
+ created_at: string;
148
+ };
149
+ }
150
+ interface HermsCountsChangedEvent {
151
+ type: 'counts.changed';
152
+ data: HermsInboxCounts;
153
+ }
154
+ type HermsRealtimeEvent = HermsItemCreatedEvent | HermsCountsChangedEvent;
155
+ type HermsEventListener = (event: HermsRealtimeEvent) => void;
156
+ /**
157
+ * One specific problem inside an error.
158
+ *
159
+ * Open, not closed, because the API says so: most entries name a request field
160
+ * (`field` + `issue`), some name a dependency instead, and its own schema declares
161
+ * `additionalProperties` rather than publishing a shape a client would be right to
162
+ * reject on. Typing it closed here would reintroduce exactly that.
163
+ */
164
+ type HermsErrorDetail = Record<string, unknown>;
165
+ /** Thrown for any failed request to the client API. It mirrors the server's error
166
+ * envelope where one was returned, and falls back to a generic shape when the body was
167
+ * not JSON at all, which is usually an upstream proxy's own HTML error page.
168
+ *
169
+ * `detail` and `docUrl` were missing for a long time while the API published both as
170
+ * required fields. A 422's per-field `detail` reached a consumer as a bare sentence,
171
+ * and `doc_url` (the one thing that lets an integrator resolve an error without
172
+ * asking anybody) never arrived at all. */
173
+ declare class HermsApiError extends Error {
174
+ readonly status: number;
175
+ readonly code: string;
176
+ readonly type: string;
177
+ readonly requestId: string;
178
+ /** Per-problem specifics. Possibly empty, never absent: the API's own words. */
179
+ readonly detail: readonly HermsErrorDetail[];
180
+ /** Documentation for this `code`, or `''` when the server sent none. */
181
+ readonly docUrl: string;
182
+ constructor(status: number, body: unknown);
183
+ }
184
+
185
+ declare class HermsClient {
186
+ private readonly options;
187
+ private readonly baseUrl;
188
+ private expiryTimer;
189
+ private lastScheduledExp;
190
+ constructor(options: HermsClientOptions);
191
+ /** Every request goes through here: always a *fresh* call to
192
+ * `getSubscriberToken()` (never cached past what the host itself returns,
193
+ * per this class's own contract), which also re-arms `onTokenExpiring`
194
+ * against whatever token comes back. */
195
+ private getFreshToken;
196
+ private scheduleExpiryHook;
197
+ /** Stops the scheduled `onTokenExpiring` timer. Called from
198
+ * `HermsProvider`'s unmount cleanup so a torn-down client never fires the
199
+ * hook after the app has stopped caring. */
200
+ destroy(): void;
201
+ /**
202
+ * `expectsBody` is how this knows a missing response body is a failure rather than a
203
+ * normal outcome. It defaults to `true` because most calls here read a payload; the
204
+ * methods that resolve to `void` pass `false`.
205
+ *
206
+ * Without it the success path did no checking at all. A `200` carrying a proxy's HTML
207
+ * page, or a `204` where a body was expected, left `parsed` null and returned it as
208
+ * `T`, so `listInbox()` rejected with `TypeError: Cannot read properties of null
209
+ * (reading 'data')` and the stack pointed at this SDK instead of at whatever was
210
+ * actually in the way. The *error* path already handled exactly that case carefully;
211
+ * only the success path did not.
212
+ */
213
+ private request;
214
+ listInbox(params?: HermsInboxListParams): Promise<HermsInboxPage>;
215
+ getCounts(): Promise<HermsInboxCounts>;
216
+ markRead(id: string): Promise<HermsInboxItem>;
217
+ markAllRead(): Promise<{
218
+ updated: number;
219
+ }>;
220
+ markSeen(ids: string[]): Promise<{
221
+ updated: number;
222
+ }>;
223
+ archive(id: string): Promise<HermsInboxItem>;
224
+ delete(id: string): Promise<void>;
225
+ registerChannel(params: HermsRegisterChannelParams): Promise<void>;
226
+ /**
227
+ * The subscriber's own notification settings, for rendering a preference centre.
228
+ *
229
+ * There is deliberately no `unsubscribe()` beside this, although the API exposes
230
+ * `POST /v1/client/unsubscribe`. That route is the target of a `List-Unsubscribe`
231
+ * one-click link (RFC 8058): its token is minted by Hermesi and embedded in an
232
+ * outgoing email's headers, and the API's own description says it is invoked by a
233
+ * mail client rather than by application code. Wrapping it here would invite a host
234
+ * to build an unsubscribe button on a token it cannot obtain. The equivalent here
235
+ * is `updatePreference({ channel: 'email', enabled: false })`.
236
+ */
237
+ getPreferences(): Promise<HermsPreferences>;
238
+ /**
239
+ * Change one setting, and receive the whole updated state back.
240
+ *
241
+ * The server answers `PATCH` with the full preferences rather than an
242
+ * acknowledgement, so a caller never has to refetch, and never renders a view
243
+ * assembled from its own optimistic guess plus whatever else has changed since.
244
+ * Omit `categoryId` for the global setting; supply it to change one category.
245
+ */
246
+ updatePreference(update: HermsPreferenceUpdate): Promise<HermsPreferences>;
247
+ /**
248
+ * **Path parameters, not a query string.** This sent
249
+ * `DELETE /channels?channel=...&identifier=...` for as long as the method has existed,
250
+ * and there is no `DELETE` on `/v1/client/channels` at all: the route is
251
+ * `/v1/client/channels/{channel}/{identifier}`, so every call 404'd. Nothing caught
252
+ * it: the reference integration never deregisters, and the package had no tests.
253
+ *
254
+ * `identifier` is encoded because it is somebody's address (an email, a phone
255
+ * number, a chat conversation id), and those carry `@`, `+` and `:`.
256
+ */
257
+ deregisterChannel(channel: HermsChannel, identifier: string): Promise<void>;
258
+ /**
259
+ * Opens a live connection for real-time inbox events and returns an
260
+ * unsubscribe function. SSR-safe: `window`/`EventSource` are only read here,
261
+ * at call time, never at module or constructor time.
262
+ *
263
+ * - Prefers SSE (`GET /inbox/stream`) when `EventSource` exists.
264
+ * - Falls back to polling `getCounts()` every 60s (§6.6) when `EventSource`
265
+ * doesn't exist at all, or after `MAX_FAST_RECONNECT_ATTEMPTS` consecutive
266
+ * immediate reconnect failures (an invalid/expired token, or a network
267
+ * that blocks SSE outright). It still retries SSE in the background with
268
+ * exponential backoff rather than abandoning it permanently, so a fixed
269
+ * token/network problem self-heals without a page reload.
270
+ */
271
+ subscribe(onEvent: HermsEventListener): () => void;
272
+ }
273
+
274
+ /**
275
+ * Pure, dependency-free helpers for the *reading* half of F-INB-2's subscriber
276
+ * token (§6.2). `HermsClient` never mints or verifies a signature (it never has
277
+ * the secret key hash to do either), it only needs to read the `exp` claim back
278
+ * out of a token the host app already handed it, to drive `onTokenExpiring`.
279
+ *
280
+ * Token shape (verbatim, `hermesi.core.security`'s "F-INB-2 subscriber token"
281
+ * section): `base64url(payload) + "." + base64url(hmac_sha256(...))`, where
282
+ * `payload` is a plain JSON object `{sub, env, exp}`. No JOSE/JWT header segment.
283
+ */
284
+ /**
285
+ * Returns the token's `exp` claim (unix seconds) or `null` for anything that
286
+ * isn't a well-formed `<payload>.<signature>` token with a numeric `exp`. A
287
+ * malformed value never throws, it just means `onTokenExpiring` won't be
288
+ * scheduled for this particular token.
289
+ */
290
+ declare function decodeSubscriberTokenExp(token: string): number | null;
291
+
292
+ interface HermsContextValue {
293
+ client: HermsClient;
294
+ /** Registers `listener` against the *one* `client.subscribe(...)` connection
295
+ * this provider owns for the whole subtree, returning an unsubscribe
296
+ * function. Every `useUnreadCount`/`useInbox` consumer calls this instead
297
+ * of opening its own `EventSource`/poll loop. */
298
+ addEventListener: (listener: HermsEventListener) => () => void;
299
+ }
300
+ interface HermsProviderProps {
301
+ client: HermsClient;
302
+ children: ReactNode;
303
+ }
304
+ /**
305
+ * `<HermsProvider client={new HermsClient({...})}>`: one `HermsClient`
306
+ * instance for the whole subtree. Opens the client's real-time subscription
307
+ * once on mount and tears it (and the client's own `onTokenExpiring` timer)
308
+ * down on unmount; every listener registered via `addEventListener` below
309
+ * shares that single connection rather than each hook paying for its own.
310
+ *
311
+ * `client` is expected to be a stable reference (constructed once, e.g. in
312
+ * `useMemo`/module scope in the host app). Passing a new instance on every
313
+ * render tears down and reopens the underlying connection every render.
314
+ */
315
+ declare function HermsProvider({ client, children }: HermsProviderProps): react.JSX.Element;
316
+ /** Every hook/component in this package throws this same error outside a
317
+ * `<HermsProvider>`: there is no sensible default client to fall back to. */
318
+ declare function useHermsContext(): HermsContextValue;
319
+
320
+ interface UseUnreadCountResult extends HermsInboxCounts {
321
+ isLoading: boolean;
322
+ }
323
+ /**
324
+ * Headless. `{unread, unseen, isLoading}`, kept live via the provider's
325
+ * shared subscription: one `getCounts()` call on mount, then recomputed on
326
+ * every `counts.changed` event (real-time push, or the client's own 60s
327
+ * polling fallback) for as long as this component stays mounted.
328
+ */
329
+ declare function useUnreadCount(): UseUnreadCountResult;
330
+
331
+ interface UseInboxOptions {
332
+ status?: HermsInboxReadStatus;
333
+ category?: string;
334
+ }
335
+ interface UseInboxResult {
336
+ items: HermsInboxItem[];
337
+ isLoading: boolean;
338
+ isLoadingMore: boolean;
339
+ error: Error | null;
340
+ hasMore: boolean;
341
+ loadMore: () => void;
342
+ markRead: (id: string) => Promise<void>;
343
+ markAllRead: () => Promise<void>;
344
+ archive: (id: string) => Promise<void>;
345
+ remove: (id: string) => Promise<void>;
346
+ refetch: () => void;
347
+ }
348
+ /**
349
+ * Headless, cursor-paginated inbox list state: thin wrappers around
350
+ * `HermsClient`'s own methods that also patch local state, so a consumer
351
+ * never has to manually refetch the whole list after every mutating action.
352
+ *
353
+ * Live-updated on `item.created`: the new item is prepended immediately from
354
+ * the (deliberately minimal: `{id, title, created_at}`, §6.6) SSE payload so
355
+ * the UI reacts instantly, then reconciled against a fresh first page fetched
356
+ * in the background so the authoritative row (body, category, action_url)
357
+ * lands without a full list reload/flicker.
358
+ */
359
+ declare function useInbox(options?: UseInboxOptions): UseInboxResult;
360
+
361
+ interface UsePreferencesResult {
362
+ /** `null` until the first read resolves. */
363
+ preferences: HermsPreferences | null;
364
+ isLoading: boolean;
365
+ /** The failure of the last read or write, or `null`. Kept rather than thrown so a
366
+ * preference centre can show it beside the controls instead of unmounting them. */
367
+ error: Error | null;
368
+ /** Change one setting. Resolves once the server's new state has been applied. */
369
+ setPreference: (update: HermsPreferenceUpdate) => Promise<void>;
370
+ /** Re-read, for a retry button. */
371
+ reload: () => Promise<void>;
372
+ }
373
+ /**
374
+ * Headless preferences, the counterpart to `useInbox`.
375
+ *
376
+ * **No optimistic update, deliberately.** A toggle here is not a like button: turning
377
+ * off a channel is a consent decision, and showing it as done before the server agreed
378
+ * is showing somebody they have opted out when they may not have. The server answers
379
+ * `PATCH` with the *whole* updated state, so the correct view arrives with the write
380
+ * and there is nothing to reconcile. A per-row pending state in the host is a better
381
+ * answer to the latency than a lie.
382
+ *
383
+ * **`error` is state, not an exception.** `setPreference` still rejects, so a caller
384
+ * can `await` it and react per-control, but the hook also holds the failure so a
385
+ * preference centre can render it without every consumer writing the same try/catch.
386
+ * A failed write leaves the last known-good state on screen rather than an empty page.
387
+ *
388
+ * Critical categories arrive with `isCritical: true` and are always delivered. Render
389
+ * them (a subscriber should see what they are receiving), but not as a control: an
390
+ * affordance that refuses is worse than none.
391
+ */
392
+ declare function usePreferences(): UsePreferencesResult;
393
+
394
+ /**
395
+ * `<HermsInbox />`'s own tiny, self-contained EN/FR string table.
396
+ *
397
+ * Deliberately **not** i18next: this package ships to arbitrary host apps
398
+ * (§8.5: "zero hard dependency on the host app's state library") that may run
399
+ * their own i18n stack, a different one, or none at all. Pulling i18next in
400
+ * as a real dependency here would be exactly the kind of host-app-shaped
401
+ * assumption the SDK is supposed to avoid, for a handful of short strings.
402
+ * `locale` mirrors §8.5's own `<HermsProvider locale="fr">` example prop.
403
+ */
404
+ type HermsLocale = 'en' | 'fr';
405
+
406
+ type HermsInboxPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
407
+ interface HermsInboxTheme {
408
+ accent?: string;
409
+ radius?: string;
410
+ }
411
+ interface HermsInboxProps {
412
+ /** Popover position relative to the bell trigger. Default `bottom-end`. */
413
+ placement?: HermsInboxPlacement;
414
+ /** Called when an item is activated (click, or Enter on a focused row).
415
+ * Lets a host app drive its own router (§8.5's own example:
416
+ * `onItemClick={(item) => navigate(item.actionUrl)}`) instead of a hard
417
+ * page navigation. When omitted, activating an item with a non-null
418
+ * `actionUrl` does a plain `window.location.assign` to it. Either way, the
419
+ * item is marked read first. */
420
+ onItemClick?: (item: HermsInboxItem) => void;
421
+ /** A couple of the most commonly re-themed CSS custom properties, settable
422
+ * without a host app writing any CSS of its own. Anything else is
423
+ * reachable by overriding `--herms-color-*`/`--herms-radius` variables
424
+ * directly (see `HermsInbox.css`). */
425
+ theme?: HermsInboxTheme;
426
+ /** `'auto'` (default) follows the visitor's OS `prefers-color-scheme`;
427
+ * `'light'`/`'dark'` force one regardless of it. */
428
+ colorScheme?: 'auto' | 'light' | 'dark';
429
+ locale?: HermsLocale;
430
+ className?: string;
431
+ }
432
+ /**
433
+ * `<HermsInbox />`: bell trigger + dropdown panel (§4.8/F-INB-5). Built
434
+ * entirely on the headless `useInbox`/`useUnreadCount` hooks; every state
435
+ * (loading, empty, error, populated) renders here, none of it invented by a
436
+ * host app. Accessible: a real `<button>` trigger with `aria-label`/
437
+ * `aria-expanded`, the panel as `role="menu"` with roving-tabindex arrow-key
438
+ * navigation, `Home`/`End`, `Escape`-to-close (native to Radix `Popover`,
439
+ * which also returns focus to the trigger), and `Enter`/`Space` (native
440
+ * `<button>` behavior) to activate the focused item.
441
+ */
442
+ declare function HermsInbox({ placement, onItemClick, theme, colorScheme, locale, className }: HermsInboxProps): react.JSX.Element;
443
+
444
+ export { HERMS_CHANNELS, HermsApiError, type HermsCategoryPreference, type HermsChannel, type HermsChannelPreference, HermsClient, type HermsClientOptions, type HermsErrorDetail, type HermsEventListener, HermsInbox, type HermsInboxCategory, type HermsInboxCounts, type HermsInboxItem, type HermsInboxListParams, type HermsInboxPage, type HermsInboxPlacement, type HermsInboxProps, type HermsInboxReadStatus, type HermsInboxTheme, type HermsLocale, type HermsPreferenceUpdate, type HermsPreferences, HermsProvider, type HermsProviderProps, type HermsRealtimeEvent, type HermsRegisterChannelParams, type UseInboxOptions, type UseInboxResult, type UsePreferencesResult, type UseUnreadCountResult, decodeSubscriberTokenExp, useHermsContext, useInbox, usePreferences, useUnreadCount };