@xtremax/pigeon 0.3.0 → 0.4.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 (41) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +40 -1
  3. package/bin/pigeon.mjs +92 -0
  4. package/dist/cjs/client.js +3 -0
  5. package/dist/cjs/inbox/client.js +4 -9
  6. package/dist/cjs/person.js +10 -0
  7. package/dist/cjs/preferences/browser-stub.js +10 -0
  8. package/dist/cjs/preferences/client.js +21 -0
  9. package/dist/cjs/preferences/handler.js +96 -0
  10. package/dist/cjs/preferences/index.js +8 -0
  11. package/dist/cjs/preferences-react/index.js +18 -0
  12. package/dist/cjs/preferences-react/labels.js +32 -0
  13. package/dist/cjs/preferences-react/notification-preferences.js +76 -0
  14. package/dist/cjs/preferences-react/preferences-client.js +72 -0
  15. package/dist/esm/client.js +3 -0
  16. package/dist/esm/inbox/client.js +2 -7
  17. package/dist/esm/person.js +7 -0
  18. package/dist/esm/preferences/browser-stub.js +7 -0
  19. package/dist/esm/preferences/client.js +18 -0
  20. package/dist/esm/preferences/handler.js +92 -0
  21. package/dist/esm/preferences/index.js +2 -0
  22. package/dist/esm/preferences-react/index.js +13 -0
  23. package/dist/esm/preferences-react/labels.js +28 -0
  24. package/dist/esm/preferences-react/notification-preferences.js +73 -0
  25. package/dist/esm/preferences-react/preferences-client.js +66 -0
  26. package/dist/types/client.d.ts +5 -10
  27. package/dist/types/contract.d.ts +27 -0
  28. package/dist/types/inbox/client.d.ts +2 -2
  29. package/dist/types/person.d.ts +2 -0
  30. package/dist/types/preferences/browser-stub.d.ts +3 -0
  31. package/dist/types/preferences/client.d.ts +18 -0
  32. package/dist/types/preferences/handler.d.ts +32 -0
  33. package/dist/types/preferences/index.d.ts +3 -0
  34. package/dist/types/preferences-react/index.d.ts +14 -0
  35. package/dist/types/preferences-react/labels.d.ts +32 -0
  36. package/dist/types/preferences-react/notification-preferences.d.ts +18 -0
  37. package/dist/types/preferences-react/preferences-client.d.ts +22 -0
  38. package/package.json +5 -2
  39. package/skills/add-pigeon-notifications/SKILL.md +337 -0
  40. package/skills/add-pigeon-notifications/references/consent.instructions.md +263 -0
  41. package/skills/add-pigeon-notifications/references/inbox-contract.instructions.md +210 -0
@@ -0,0 +1,92 @@
1
+ import { hostCodeOf, hostData, hostError } from '../inbox/errors.js';
2
+ import { originAllowed } from '../inbox/origin.js';
3
+ import { INBOX_LIMITS, parseBody } from '../inbox/validate.js';
4
+ import { createPreferencesClient } from './client.js';
5
+ export const PREFERENCE_MEDIUMS = ['email', 'whatsapp', 'in_app'];
6
+ /** Allowlist projection: only the documented fields leave the host. A value of the wrong shape gives `null`. */
7
+ function projectChoice(v) {
8
+ if (typeof v !== 'object' || v === null)
9
+ return null;
10
+ const o = v;
11
+ const medium = PREFERENCE_MEDIUMS.find((m) => m === o.medium);
12
+ if (!medium)
13
+ return null;
14
+ if (o.state !== 'opted_in' && o.state !== 'opted_out' && o.state !== 'not_set')
15
+ return null;
16
+ if (o.changedAt !== null && typeof o.changedAt !== 'string')
17
+ return null;
18
+ if (typeof o.delivers !== 'boolean' || typeof o.available !== 'boolean')
19
+ return null;
20
+ if (o.covers !== 'tenant' && o.covers !== 'app')
21
+ return null;
22
+ return { medium, state: o.state, changedAt: o.changedAt, delivers: o.delivers, covers: o.covers, available: o.available };
23
+ }
24
+ /**
25
+ * The host app's browser-facing route for a person's notification choices, in one tested function:
26
+ *
27
+ * // app/api/communication/preferences/route.ts
28
+ * const handler = createPreferencesHandler({ resolvePerson });
29
+ * export { handler as GET, handler as PUT };
30
+ *
31
+ * `GET` answers the signed-in person's choices; `PUT` with `{ medium, state }` records one and answers them again.
32
+ * The person comes only from `resolvePerson`. A `PUT` from another origin is refused. The host stores nothing.
33
+ */
34
+ export function createPreferencesHandler(options) {
35
+ const base = (options.basePath ?? '/api/communication/preferences').replace(/\/+$/, '');
36
+ const mediums = options.mediums && options.mediums.length > 0 ? PREFERENCE_MEDIUMS.filter((m) => options.mediums.includes(m)) : PREFERENCE_MEDIUMS;
37
+ const evidence = { method: options.evidence?.method ?? 'settings_page', ...(options.evidence?.wordingVersion ? { wordingVersion: options.evidence.wordingVersion } : {}) };
38
+ let made;
39
+ const clientOf = () => options.client ?? (made ??= createPreferencesClient());
40
+ return async function preferencesHandler(request) {
41
+ try {
42
+ const method = request.method.toUpperCase();
43
+ if (new URL(request.url).pathname.replace(/\/+$/, '') !== base || (method !== 'GET' && method !== 'PUT'))
44
+ return hostError('not_found');
45
+ if (method === 'PUT' && !originAllowed(request, options.allowedOrigins))
46
+ return hostError('not_permitted');
47
+ const person = await options.resolvePerson(request);
48
+ if (typeof person !== 'string' || person.length === 0 || person.length > INBOX_LIMITS.personMax)
49
+ return hostError('not_signed_in');
50
+ const client = clientOf();
51
+ if (method === 'GET')
52
+ return answer(await client.get(person, { retry: false, timeoutMs: options.timeoutMs ?? 8000 }));
53
+ const body = parseBody(await request.text());
54
+ if (!body.ok)
55
+ return hostError('invalid_input', body.field);
56
+ const medium = mediums.find((m) => m === body.value.medium);
57
+ if (!medium)
58
+ return hostError('invalid_input', 'medium');
59
+ const state = body.value.state;
60
+ if (state !== 'opted_in' && state !== 'opted_out')
61
+ return hostError('invalid_input', 'state');
62
+ return answer(await client.set(person, medium, state, evidence, { retry: false, timeoutMs: options.timeoutMs ?? 8000 }));
63
+ }
64
+ catch (error) {
65
+ const code = hostCodeOf(error);
66
+ if (code === 'unavailable')
67
+ report(error);
68
+ return hostError(code);
69
+ }
70
+ };
71
+ function answer(data) {
72
+ const preferences = [];
73
+ for (const raw of Array.isArray(data) ? data : [null]) {
74
+ const choice = projectChoice(raw);
75
+ if (!choice) {
76
+ report(new Error('The preferences answer did not have the documented shape'));
77
+ return hostError('unavailable');
78
+ }
79
+ if (mediums.includes(choice.medium))
80
+ preferences.push(choice);
81
+ }
82
+ return hostData({ preferences });
83
+ }
84
+ function report(error) {
85
+ try {
86
+ options.onError?.(error);
87
+ }
88
+ catch {
89
+ // A failing logger must not change the answer.
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,2 @@
1
+ export { createPreferencesClient } from './client.js';
2
+ export { createPreferencesHandler, PREFERENCE_MEDIUMS } from './handler.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A person's notification choices for a settings page: one switch per medium the app can send on.
3
+ *
4
+ * import { NotificationPreferences } from '@xtremax/pigeon/preferences-react';
5
+ *
6
+ * <NotificationPreferences />
7
+ *
8
+ * The component calls the host app's own route (default `/api/communication/preferences`), which
9
+ * `createPreferencesHandler` from `@xtremax/pigeon/preferences` serves. It needs React 19 and Mantine 8, and no
10
+ * stylesheet of its own. Nothing here holds a key or talks to another origin.
11
+ */
12
+ export { NotificationPreferences } from './notification-preferences.js';
13
+ export { DEFAULT_PREFERENCE_LABELS } from './labels.js';
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The sentences say what each switch covers, and Pigeon says which applies (`covers` in its answer): a choice that
3
+ * holds in every app of the organisation, or in this app only. Keep that when you translate.
4
+ */
5
+ export const DEFAULT_PREFERENCE_LABELS = {
6
+ group: 'Notification preferences',
7
+ email: 'Email',
8
+ emailOn: 'You receive email from every app of your organisation.',
9
+ emailOff: 'You receive no email from any app of your organisation, except messages you must get, such as receipts and security alerts.',
10
+ emailOnApp: 'You receive email from this app.',
11
+ emailOffApp: 'You receive no email from this app, except messages you must get, such as receipts and security alerts. Other apps are not affected.',
12
+ whatsapp: 'WhatsApp',
13
+ whatsappOn: 'You receive WhatsApp messages from every app of your organisation.',
14
+ whatsappOff: 'You receive no WhatsApp messages from any app of your organisation.',
15
+ whatsappOnApp: 'You receive WhatsApp messages from this app.',
16
+ whatsappOffApp: 'You receive no WhatsApp messages from this app. Other apps are not affected.',
17
+ inApp: 'Notifications in this app',
18
+ inAppOn: 'New notifications from this app appear under the bell.',
19
+ inAppOff: 'New notifications from this app do not appear. The ones you already have stay. Other apps are not affected.',
20
+ loading: 'Loading your notification preferences',
21
+ loadError: 'Your notification preferences could not be loaded.',
22
+ retry: 'Try again',
23
+ saveError: 'Your choice was not saved. Try again.',
24
+ none: 'There is nothing to choose here yet.',
25
+ };
26
+ export function mergePreferenceLabels(labels) {
27
+ return { ...DEFAULT_PREFERENCE_LABELS, ...(labels ?? {}) };
28
+ }
@@ -0,0 +1,73 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import { Alert, Button, Center, Loader, Stack, Switch, Text } from '@mantine/core';
5
+ import { mergePreferenceLabels } from './labels.js';
6
+ import { createPreferencesBrowserClient } from './preferences-client.js';
7
+ const words = (labels, choice) => {
8
+ // Pigeon says what a choice covers, so the sentence stays true if that changes on Pigeon's side.
9
+ const own = choice.covers === 'app';
10
+ if (choice.medium === 'email')
11
+ return { label: labels.email, description: choice.delivers ? (own ? labels.emailOnApp : labels.emailOn) : own ? labels.emailOffApp : labels.emailOff };
12
+ if (choice.medium === 'whatsapp')
13
+ return { label: labels.whatsapp, description: choice.delivers ? (own ? labels.whatsappOnApp : labels.whatsappOn) : own ? labels.whatsappOffApp : labels.whatsappOff };
14
+ return { label: labels.inApp, description: choice.delivers ? labels.inAppOn : labels.inAppOff };
15
+ };
16
+ /**
17
+ * A person's notification choices: one switch per medium, with a sentence that says what the switch covers.
18
+ * It shows what is recorded: a switch moves when the choice is saved, not before. Place it on a settings page,
19
+ * inside `MantineProvider`. It calls the host app's own route, which `createPreferencesHandler` serves.
20
+ */
21
+ export function NotificationPreferences({ apiBase, labels, mediums, onChange }) {
22
+ const client = useMemo(() => createPreferencesBrowserClient(apiBase), [apiBase]);
23
+ const merged = useMemo(() => mergePreferenceLabels(labels), [labels]);
24
+ const [choices, setChoices] = useState(null);
25
+ const [status, setStatus] = useState('loading');
26
+ const [saving, setSaving] = useState(null);
27
+ const [saveFailed, setSaveFailed] = useState(false);
28
+ const load = useCallback(async () => {
29
+ setStatus('loading');
30
+ const res = await client.get();
31
+ if (res.ok)
32
+ setChoices(res.choices);
33
+ setStatus(res.ok ? 'ready' : 'error');
34
+ }, [client]);
35
+ useEffect(() => {
36
+ void load();
37
+ }, [load]);
38
+ const change = async (medium, on) => {
39
+ if (saving)
40
+ return;
41
+ setSaving(medium);
42
+ setSaveFailed(false);
43
+ const res = await client.set(medium, on);
44
+ setSaving(null);
45
+ if (!res.ok) {
46
+ setSaveFailed(true);
47
+ return;
48
+ }
49
+ setChoices(res.choices);
50
+ const saved = res.choices.find((choice) => choice.medium === medium);
51
+ // A choice that was recorded and still does not hold (for example a suspended person) is said, not hidden.
52
+ if (!saved || saved.delivers !== on)
53
+ setSaveFailed(true);
54
+ else
55
+ onChange?.(saved);
56
+ };
57
+ if (status === 'loading' && !choices) {
58
+ return (_jsx(Center, { mih: 96, role: "status", "aria-label": merged.loading, children: _jsx(Loader, { size: "sm" }) }));
59
+ }
60
+ if (!choices) {
61
+ return (_jsx(Alert, { color: "red", variant: "light", title: merged.loadError, children: _jsx(Button, { variant: "default", size: "sm", mih: 44, onClick: () => void load(), children: merged.retry }) }));
62
+ }
63
+ const shown = mediums
64
+ ? mediums.flatMap((medium) => choices.filter((choice) => choice.medium === medium))
65
+ : choices.filter((choice) => choice.available);
66
+ if (shown.length === 0) {
67
+ return (_jsx(Text, { c: "dimmed", size: "sm", children: merged.none }));
68
+ }
69
+ return (_jsxs(Stack, { gap: "md", role: "group", "aria-label": merged.group, children: [shown.map((choice) => {
70
+ const { label, description } = words(merged, choice);
71
+ return (_jsx(Switch, { label: label, description: description, checked: choice.delivers, disabled: saving !== null, onChange: (event) => void change(choice.medium, event.currentTarget.checked) }, choice.medium));
72
+ }), saveFailed && (_jsx(Alert, { color: "red", variant: "light", role: "alert", children: merged.saveError }))] }));
73
+ }
@@ -0,0 +1,66 @@
1
+ import { normalizeApiBase } from '../inbox-react/inbox-client.js';
2
+ export const DEFAULT_PREFERENCES_API_BASE = '/api/communication/preferences';
3
+ export const PREFERENCE_MEDIUMS = ['email', 'whatsapp', 'in_app'];
4
+ /** A choice as the host route answers it. Anything else is refused, so a wrong answer never becomes a switch. */
5
+ export function parseChoice(v) {
6
+ if (typeof v !== 'object' || v === null)
7
+ return null;
8
+ const o = v;
9
+ const medium = PREFERENCE_MEDIUMS.find((m) => m === o.medium);
10
+ if (!medium)
11
+ return null;
12
+ if (o.state !== 'opted_in' && o.state !== 'opted_out' && o.state !== 'not_set')
13
+ return null;
14
+ if (o.changedAt !== null && typeof o.changedAt !== 'string')
15
+ return null;
16
+ if (typeof o.delivers !== 'boolean' || typeof o.available !== 'boolean')
17
+ return null;
18
+ if (o.covers !== 'tenant' && o.covers !== 'app')
19
+ return null;
20
+ return { medium, state: o.state, changedAt: o.changedAt, delivers: o.delivers, covers: o.covers, available: o.available };
21
+ }
22
+ export function parseChoices(v) {
23
+ const list = v?.preferences;
24
+ if (!Array.isArray(list))
25
+ return null;
26
+ const choices = [];
27
+ for (const raw of list) {
28
+ const choice = parseChoice(raw);
29
+ if (!choice)
30
+ return null;
31
+ choices.push(choice);
32
+ }
33
+ return choices;
34
+ }
35
+ /**
36
+ * The browser talks only to the host app's own route. Every call answers a result and never throws, so an outage
37
+ * cannot break the host page.
38
+ */
39
+ export function createPreferencesBrowserClient(apiBase, fetchImpl) {
40
+ const base = normalizeApiBase(apiBase ?? DEFAULT_PREFERENCES_API_BASE);
41
+ async function call(method, body) {
42
+ if (base === null)
43
+ return { ok: false, code: 'unavailable' };
44
+ try {
45
+ const res = await (fetchImpl ?? fetch)(base, {
46
+ method,
47
+ credentials: 'same-origin',
48
+ cache: 'no-store',
49
+ headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
50
+ body: body === undefined ? undefined : JSON.stringify(body),
51
+ });
52
+ if (res.status === 401)
53
+ return { ok: false, code: 'not_signed_in' };
54
+ const json = await res.json().catch(() => null);
55
+ const choices = res.ok ? parseChoices(json?.data) : null;
56
+ return choices ? { ok: true, choices } : { ok: false, code: 'unavailable' };
57
+ }
58
+ catch {
59
+ return { ok: false, code: 'unavailable' };
60
+ }
61
+ }
62
+ return {
63
+ get: () => call('GET'),
64
+ set: (medium, on) => call('PUT', { medium, state: on ? 'opted_in' : 'opted_out' }),
65
+ };
66
+ }
@@ -1,4 +1,4 @@
1
- import type { AcceptedMessage, ChannelInfo, MessageView, PingInfo, RecipientView, SendMessageRequest } from './contract.js';
1
+ import type { AcceptedMessage, ChannelInfo, MessageView, PingInfo, PreferenceChoice, PreferenceEvidence, PreferenceRecorded, RecipientView, SendMessageRequest } from './contract.js';
2
2
  import { type RetryPolicy } from './retry.js';
3
3
  export interface ClientOptions {
4
4
  /** Defaults to `process.env.COMMUNICATION_API_KEY`. */
@@ -56,6 +56,8 @@ export declare function createCommunicationClient(options?: ClientOptions): {
56
56
  deleteAddress(personId: string, addressId: string): Promise<void>;
57
57
  };
58
58
  preferences: {
59
+ /** A person's choice for each medium, and whether a message reaches them now. Someone Pigeon has not met has made no choice. */
60
+ get: (personId: string) => Promise<PreferenceChoice[]>;
59
61
  record: (body: {
60
62
  recipient: {
61
63
  personId?: string;
@@ -65,15 +67,8 @@ export declare function createCommunicationClient(options?: ClientOptions): {
65
67
  medium: "email" | "whatsapp" | "in_app";
66
68
  state: "opted_in" | "opted_out";
67
69
  expressedAt?: string;
68
- evidence?: {
69
- method?: string;
70
- wordingVersion?: string;
71
- proofRef?: string;
72
- };
73
- }) => Promise<{
74
- changed: boolean;
75
- current: unknown;
76
- }>;
70
+ evidence?: PreferenceEvidence;
71
+ }) => Promise<PreferenceRecorded>;
77
72
  };
78
73
  messageClasses: {
79
74
  put: (templateKey: string, body: {
@@ -130,6 +130,33 @@ export interface RecipientView {
130
130
  source: string | null;
131
131
  }>;
132
132
  }
133
+ export type PreferenceMedium = 'email' | 'whatsapp' | 'in_app';
134
+ export interface PreferenceChoice {
135
+ medium: PreferenceMedium;
136
+ state: 'opted_in' | 'opted_out' | 'not_set';
137
+ changedAt: string | null;
138
+ /** Whether a message on this medium reaches the person now: the choice, the tenant's consent mode and the person's status together. */
139
+ delivers: boolean;
140
+ /** `tenant`: the choice holds in every application of the tenant. `app`: it holds for the calling application only. */
141
+ covers: 'tenant' | 'app';
142
+ /** Whether the calling key has a ready channel of this medium. */
143
+ available: boolean;
144
+ }
145
+ /** How a choice was made: where, which wording the person saw, and your own record of it. */
146
+ export interface PreferenceEvidence {
147
+ method?: string;
148
+ wordingVersion?: string;
149
+ proofRef?: string;
150
+ }
151
+ export interface PreferenceRecorded {
152
+ changed: boolean;
153
+ current: {
154
+ medium: string;
155
+ state: 'opted_in' | 'opted_out' | 'not_set';
156
+ changedAt: string | null;
157
+ source: string | null;
158
+ };
159
+ }
133
160
  export interface InboxNotification {
134
161
  /** The inbox item, not the message. */
135
162
  id: string;
@@ -1,4 +1,5 @@
1
1
  import { type ClientOptions, type CommunicationClient } from '../client.js';
2
+ import { personHeader } from '../person.js';
2
3
  import type { InboxMarkReadResult, InboxNotificationPage, InboxPreference, InboxReadAllResult, InboxSummary } from '../contract.js';
3
4
  export interface CallOptions {
4
5
  /** Reads are retried by the SDK's policy unless this is `false`. Writes make one attempt unless this is `true`. */
@@ -25,10 +26,9 @@ export interface InboxClient {
25
26
  getPreference(personId: string, options?: CallOptions): Promise<InboxPreference>;
26
27
  setPreference(personId: string, state: 'opted_in' | 'opted_out', options?: CallOptions): Promise<InboxPreference>;
27
28
  }
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
29
  /**
31
30
  * The in-app inbox of one person. Pass an existing client to share its key and settings, or the same options as
32
31
  * `createCommunicationClient`. The key needs the scopes `inbox:read` and `inbox:write`.
33
32
  */
34
33
  export declare function createInboxClient(client?: ClientOptions | CommunicationClient): InboxClient;
34
+ export { personHeader };
@@ -0,0 +1,2 @@
1
+ /** `X-Communication-Person` is base64url of the UTF-8 person id, so any identifier travels safely in a header. */
2
+ export declare function personHeader(personId: string): string;
@@ -0,0 +1,3 @@
1
+ /** Selected by the `browser` export condition: this entry never reaches client code. The component talks to the host's own route instead. */
2
+ export declare const createPreferencesClient: () => never;
3
+ export declare const createPreferencesHandler: () => never;
@@ -0,0 +1,18 @@
1
+ import { type ClientOptions, type CommunicationClient } from '../client.js';
2
+ import type { PreferenceChoice, PreferenceEvidence, PreferenceMedium } from '../contract.js';
3
+ import type { CallOptions } from '../inbox/client.js';
4
+ /**
5
+ * A person's consent choices as this key meets them: email and WhatsApp for the whole tenant, in-app for this key's
6
+ * own sub-tenant (or the tenant level, for a key outside any).
7
+ */
8
+ export interface PreferencesClient {
9
+ /** One entry per medium. Someone Pigeon has not met has made no choice. */
10
+ get(personId: string, options?: CallOptions): Promise<PreferenceChoice[]>;
11
+ /** Records one choice with how it was made, and answers the choices as they are afterwards. */
12
+ set(personId: string, medium: PreferenceMedium, state: 'opted_in' | 'opted_out', evidence?: PreferenceEvidence, options?: CallOptions): Promise<PreferenceChoice[]>;
13
+ }
14
+ /**
15
+ * Pass an existing client to share its key and settings, or the same options as `createCommunicationClient`.
16
+ * The key needs the scopes `recipients:read` and `consent:write`.
17
+ */
18
+ export declare function createPreferencesClient(client?: ClientOptions | CommunicationClient): PreferencesClient;
@@ -0,0 +1,32 @@
1
+ import type { PreferenceEvidence, PreferenceMedium } from '../contract.js';
2
+ import { type PreferencesClient } from './client.js';
3
+ export declare const PREFERENCE_MEDIUMS: readonly PreferenceMedium[];
4
+ export interface PreferencesHandlerOptions {
5
+ /** The app'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
+ /** Default: a client made on the first request from `COMMUNICATION_API_KEY` and `COMMUNICATION_URL`. */
8
+ client?: PreferencesClient;
9
+ /** Where the route is mounted. Default `/api/communication/preferences`. */
10
+ basePath?: string;
11
+ /** The media a person may see and change through this route. Default: all three. */
12
+ mediums?: readonly PreferenceMedium[];
13
+ /** Recorded with every choice. `method` defaults to `settings_page`; set `wordingVersion` and change it when the text beside the switches changes. */
14
+ evidence?: Pick<PreferenceEvidence, 'method' | 'wordingVersion'>;
15
+ /** Origins that may send PUT. Default: the request's own origin. */
16
+ allowedOrigins?: readonly string[];
17
+ /** Default 8000. */
18
+ timeoutMs?: number;
19
+ /** Told about every failure answered as `unavailable`, for the host's own log. The browser never sees it. */
20
+ onError?: (error: unknown) => void;
21
+ }
22
+ /**
23
+ * The host app's browser-facing route for a person's notification choices, in one tested function:
24
+ *
25
+ * // app/api/communication/preferences/route.ts
26
+ * const handler = createPreferencesHandler({ resolvePerson });
27
+ * export { handler as GET, handler as PUT };
28
+ *
29
+ * `GET` answers the signed-in person's choices; `PUT` with `{ medium, state }` records one and answers them again.
30
+ * The person comes only from `resolvePerson`. A `PUT` from another origin is refused. The host stores nothing.
31
+ */
32
+ export declare function createPreferencesHandler(options: PreferencesHandlerOptions): (request: Request) => Promise<Response>;
@@ -0,0 +1,3 @@
1
+ export { createPreferencesClient, type PreferencesClient } from './client.js';
2
+ export { createPreferencesHandler, PREFERENCE_MEDIUMS, type PreferencesHandlerOptions } from './handler.js';
3
+ export type { PreferenceChoice, PreferenceEvidence, PreferenceMedium } from '../contract.js';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A person's notification choices for a settings page: one switch per medium the app can send on.
3
+ *
4
+ * import { NotificationPreferences } from '@xtremax/pigeon/preferences-react';
5
+ *
6
+ * <NotificationPreferences />
7
+ *
8
+ * The component calls the host app's own route (default `/api/communication/preferences`), which
9
+ * `createPreferencesHandler` from `@xtremax/pigeon/preferences` serves. It needs React 19 and Mantine 8, and no
10
+ * stylesheet of its own. Nothing here holds a key or talks to another origin.
11
+ */
12
+ export { NotificationPreferences, type NotificationPreferencesProps } from './notification-preferences.js';
13
+ export { DEFAULT_PREFERENCE_LABELS, type PreferenceLabels } from './labels.js';
14
+ export type { PreferenceChoice, PreferenceMedium } from '../contract.js';
@@ -0,0 +1,32 @@
1
+ /** Every visible and accessible text of the preferences component. Pass `labels` to replace any of them. */
2
+ export interface PreferenceLabels {
3
+ /** The accessible name of the group of switches. */
4
+ group: string;
5
+ email: string;
6
+ /** Email, when the choice holds in every app of the organisation. */
7
+ emailOn: string;
8
+ emailOff: string;
9
+ /** Email, when Pigeon says the choice holds for this app only (`covers: 'app'`). */
10
+ emailOnApp: string;
11
+ emailOffApp: string;
12
+ whatsapp: string;
13
+ whatsappOn: string;
14
+ whatsappOff: string;
15
+ whatsappOnApp: string;
16
+ whatsappOffApp: string;
17
+ inApp: string;
18
+ inAppOn: string;
19
+ inAppOff: string;
20
+ loading: string;
21
+ loadError: string;
22
+ retry: string;
23
+ saveError: string;
24
+ /** Shown when the app can send on no medium. */
25
+ none: string;
26
+ }
27
+ /**
28
+ * The sentences say what each switch covers, and Pigeon says which applies (`covers` in its answer): a choice that
29
+ * holds in every app of the organisation, or in this app only. Keep that when you translate.
30
+ */
31
+ export declare const DEFAULT_PREFERENCE_LABELS: PreferenceLabels;
32
+ export declare function mergePreferenceLabels(labels?: Partial<PreferenceLabels>): PreferenceLabels;
@@ -0,0 +1,18 @@
1
+ import type { PreferenceChoice, PreferenceMedium } from '../contract.js';
2
+ import { type PreferenceLabels } from './labels.js';
3
+ export interface NotificationPreferencesProps {
4
+ /** A path on the host's own origin. Default `/api/communication/preferences`. */
5
+ apiBase?: string;
6
+ /** Replaces any visible or accessible text. */
7
+ labels?: Partial<PreferenceLabels>;
8
+ /** Which media to show, in this order. Default: the ones the app can send on. */
9
+ mediums?: readonly PreferenceMedium[];
10
+ /** Told after a choice was saved. */
11
+ onChange?: (choice: PreferenceChoice) => void;
12
+ }
13
+ /**
14
+ * A person's notification choices: one switch per medium, with a sentence that says what the switch covers.
15
+ * It shows what is recorded: a switch moves when the choice is saved, not before. Place it on a settings page,
16
+ * inside `MantineProvider`. It calls the host app's own route, which `createPreferencesHandler` serves.
17
+ */
18
+ export declare function NotificationPreferences({ apiBase, labels, mediums, onChange }: NotificationPreferencesProps): import("react").JSX.Element;
@@ -0,0 +1,22 @@
1
+ import type { PreferenceChoice, PreferenceMedium } from '../contract.js';
2
+ export declare const DEFAULT_PREFERENCES_API_BASE = "/api/communication/preferences";
3
+ export declare const PREFERENCE_MEDIUMS: readonly PreferenceMedium[];
4
+ export type PreferencesResult = {
5
+ ok: true;
6
+ choices: PreferenceChoice[];
7
+ } | {
8
+ ok: false;
9
+ code: 'not_signed_in' | 'unavailable';
10
+ };
11
+ /** A choice as the host route answers it. Anything else is refused, so a wrong answer never becomes a switch. */
12
+ export declare function parseChoice(v: unknown): PreferenceChoice | null;
13
+ export declare function parseChoices(v: unknown): PreferenceChoice[] | null;
14
+ export interface PreferencesBrowserClient {
15
+ get(): Promise<PreferencesResult>;
16
+ set(medium: PreferenceMedium, on: boolean): Promise<PreferencesResult>;
17
+ }
18
+ /**
19
+ * The browser talks only to the host app's own route. Every call answers a result and never throws, so an outage
20
+ * cannot break the host page.
21
+ */
22
+ export declare function createPreferencesBrowserClient(apiBase?: string, fetchImpl?: typeof fetch): PreferencesBrowserClient;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtremax/pigeon",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Client for Pigeon, the Buildpad Communication Gateway: send messages, verify signed events, serve an in-app inbox and show its notification bell.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -15,10 +15,13 @@
15
15
  "./inbox": { "browser": "./dist/esm/inbox/browser-stub.js", "types": "./dist/types/inbox/index.d.ts", "import": "./dist/esm/inbox/index.js", "require": "./dist/cjs/inbox/index.js" },
16
16
  "./inbox-react": { "types": "./dist/types/inbox-react/index.d.ts", "import": "./dist/esm/inbox-react/index.js", "require": "./dist/cjs/inbox-react/index.js" },
17
17
  "./inbox-react/styles.css": "./dist/inbox-react/styles.css",
18
+ "./preferences": { "browser": "./dist/esm/preferences/browser-stub.js", "types": "./dist/types/preferences/index.d.ts", "import": "./dist/esm/preferences/index.js", "require": "./dist/cjs/preferences/index.js" },
19
+ "./preferences-react": { "types": "./dist/types/preferences-react/index.d.ts", "import": "./dist/esm/preferences-react/index.js", "require": "./dist/cjs/preferences-react/index.js" },
18
20
  "./events": { "types": "./dist/types/events.d.ts", "import": "./dist/esm/events.js", "require": "./dist/cjs/events.js" },
19
21
  "./contract": { "types": "./dist/types/contract.d.ts" }
20
22
  },
21
- "files": ["dist", "README.md", "CHANGELOG.md"],
23
+ "bin": { "pigeon": "bin/pigeon.mjs" },
24
+ "files": ["dist", "bin", "skills", "README.md", "CHANGELOG.md"],
22
25
  "scripts": {
23
26
  "build": "node scripts/build.mjs",
24
27
  "prepublishOnly": "node scripts/build.mjs"