@xtremax/pigeon 0.3.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1
4
+ - The package carries its agent skill, `skills/add-pigeon-notifications/`: the steps a coding agent follows to add the bell and the sending code to an app.
5
+ - New command `npx @xtremax/pigeon add-skill`: copies the skill into the project's `.claude/skills/`, and into `.kiro/skills/` when the project has a `.kiro` folder. Run it again after an upgrade to get the new version's skill. `--check` says what would change.
6
+ - No change to the library.
7
+
3
8
  ## 0.3.0
4
9
  - The package is named `@xtremax/pigeon`. Earlier versions were called `@xtremax/communication` and were never published. Only the import path changes: function names, environment variables and headers are the same.
5
10
  - New entry `@xtremax/pigeon/inbox-react`: the notification bell (`InboxProvider`, `NotificationBell`, `InboxFullList`), with its stylesheet at `@xtremax/pigeon/inbox-react/styles.css`. It replaces the files that were copied into each app. React 19, Mantine 8 and `@tabler/icons-react` are optional peer dependencies.
package/README.md CHANGED
@@ -54,4 +54,16 @@ import '@xtremax/pigeon/inbox-react/styles.css'; // once, after Mantine's styles
54
54
  - **Direct calls:** `createInboxClient()` has `summary`, `list`, `markRead`, `markAllRead`, `getPreference` and `setPreference`, each taking the person id first.
55
55
  - **What a key sees:** what its own sub-tenant (or the tenant level, for a key outside any) sent the person, and the tenant's announcements. Another app's notifications are not part of it, and the on/off preference is this app's own. `ping()` returns the key's `subKey`.
56
56
 
57
+ ## For AI coding agents
58
+
59
+ The package carries a skill that tells a coding agent (Claude Code, Kiro and others that read `SKILL.md`) how to add the bell and the sending code to an app, step by step, with tests and a troubleshooting table.
60
+
61
+ ```bash
62
+ npx @xtremax/pigeon add-skill
63
+ ```
64
+
65
+ It copies `add-pigeon-notifications` into the project's `.claude/skills/`, and into `.kiro/skills/` when the project has a `.kiro` folder (`--kiro` forces it, `--dir` names another project folder, `--check` changes nothing and says what would change). Then ask the agent to "add a notification bell", or run `/add-pigeon-notifications`. Run the command again after upgrading the package: the skill's files are replaced with the new version's.
66
+
67
+ An agent that has not been given the skill can read it in place: `node_modules/@xtremax/pigeon/skills/add-pigeon-notifications/SKILL.md`.
68
+
57
69
  Entry points: `/client`, `/inbox`, `/events` and `/contract` (types only) for the server; `/inbox-react` and `/inbox-react/styles.css` for the browser. Node 20 or later. Below 1.0 a breaking change raises the minor version.
package/bin/pigeon.mjs ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ // The package's command line. One job: put the agent skills that ship with the package where a coding agent finds them.
3
+ //
4
+ // npx @xtremax/pigeon add-skill copy the skills into this project
5
+ // npx @xtremax/pigeon add-skill --check say what would change, and change nothing
6
+ // npx @xtremax/pigeon add-skill --dir app another project folder
7
+ //
8
+ // The skills go to .claude/skills/ always, and to .kiro/skills/ when the project has a .kiro folder (or with --kiro).
9
+ // Run it again after upgrading the package: it replaces the skill's files with the new version's.
10
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join, relative, resolve } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
15
+ const skillsRoot = join(packageRoot, 'skills');
16
+ const { name, version } = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
17
+
18
+ const USAGE = `${name} ${version}
19
+
20
+ Usage:
21
+ npx ${name} add-skill [--dir <project folder>] [--kiro] [--check]
22
+
23
+ add-skill Copy the agent skills of this package into the project:
24
+ .claude/skills/<skill>/, and .kiro/skills/<skill>/ when the project has a .kiro folder.
25
+ --dir The project folder. Default: the current folder.
26
+ --kiro Also write .kiro/skills/ when the project has no .kiro folder yet.
27
+ --check Say what would change and change nothing. Exits 1 when something would.
28
+ `;
29
+
30
+ function fail(message) {
31
+ process.stderr.write(`${message}\n\n${USAGE}`);
32
+ process.exit(2);
33
+ }
34
+
35
+ /** Every file under a folder, as paths relative to it. */
36
+ function filesOf(dir, base = dir) {
37
+ return readdirSync(dir).flatMap((entry) => {
38
+ const path = join(dir, entry);
39
+ return statSync(path).isDirectory() ? filesOf(path, base) : [relative(base, path)];
40
+ });
41
+ }
42
+
43
+ function addSkill(args) {
44
+ let dir = process.cwd();
45
+ let kiro = false;
46
+ let check = false;
47
+ for (let i = 0; i < args.length; i += 1) {
48
+ const arg = args[i];
49
+ if (arg === '--dir') {
50
+ const value = args[(i += 1)];
51
+ if (!value) fail('--dir needs a folder.');
52
+ dir = resolve(value);
53
+ } else if (arg === '--kiro') kiro = true;
54
+ else if (arg === '--check') check = true;
55
+ else fail(`Unknown option: ${arg}`);
56
+ }
57
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) fail(`Not a folder: ${dir}`);
58
+ // A project, not any folder: writing skills into the wrong place helps nobody.
59
+ if (!existsSync(join(dir, 'package.json'))) fail(`No package.json in ${dir}. Run this in the project's folder, or pass --dir.`);
60
+
61
+ const skills = readdirSync(skillsRoot).filter((entry) => existsSync(join(skillsRoot, entry, 'SKILL.md')));
62
+ const homes = ['.claude/skills', ...(kiro || existsSync(join(dir, '.kiro')) ? ['.kiro/skills'] : [])];
63
+ const counts = { added: 0, updated: 0, same: 0 };
64
+
65
+ for (const home of homes) {
66
+ for (const skill of skills) {
67
+ for (const file of filesOf(join(skillsRoot, skill))) {
68
+ const content = readFileSync(join(skillsRoot, skill, file));
69
+ const target = join(dir, home, skill, file);
70
+ const state = !existsSync(target) ? 'added' : readFileSync(target).equals(content) ? 'same' : 'updated';
71
+ counts[state] += 1;
72
+ if (state === 'same') continue;
73
+ process.stdout.write(`${check ? `would be ${state}` : state}: ${join(home, skill, file)}\n`);
74
+ if (check) continue;
75
+ mkdirSync(dirname(target), { recursive: true });
76
+ writeFileSync(target, content);
77
+ }
78
+ }
79
+ }
80
+
81
+ const changed = counts.added + counts.updated;
82
+ if (changed === 0) process.stdout.write(`Up to date: ${skills.join(', ')} (${name} ${version}) in ${homes.join(' and ')}.\n`);
83
+ else if (check) process.stdout.write(`${changed} file(s) would change. Run without --check to write them.\n`);
84
+ else process.stdout.write(`\nDone: ${skills.join(', ')} (${name} ${version}) in ${homes.join(' and ')}.\nAsk your coding agent to "add a notification bell", or run /${skills[0]}.\n`);
85
+ process.exit(check && changed > 0 ? 1 : 0);
86
+ }
87
+
88
+ const [command, ...rest] = process.argv.slice(2);
89
+ if (command === 'add-skill') addSkill(rest);
90
+ else if (command === undefined || command === 'help' || command === '--help' || command === '-h') process.stdout.write(USAGE);
91
+ else if (command === '--version' || command === '-v') process.stdout.write(`${version}\n`);
92
+ else fail(`Unknown command: ${command}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xtremax/pigeon",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
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",
@@ -18,7 +18,8 @@
18
18
  "./events": { "types": "./dist/types/events.d.ts", "import": "./dist/esm/events.js", "require": "./dist/cjs/events.js" },
19
19
  "./contract": { "types": "./dist/types/contract.d.ts" }
20
20
  },
21
- "files": ["dist", "README.md", "CHANGELOG.md"],
21
+ "bin": { "pigeon": "./bin/pigeon.mjs" },
22
+ "files": ["dist", "bin", "skills", "README.md", "CHANGELOG.md"],
22
23
  "scripts": {
23
24
  "build": "node scripts/build.mjs",
24
25
  "prepublishOnly": "node scripts/build.mjs"
@@ -0,0 +1,302 @@
1
+ ---
2
+ name: add-pigeon-notifications
3
+ description: Add in-app notifications to an app with Pigeon (the Buildpad Communication Gateway) - a notification bell with an unread badge, a panel, a full list and an on/off preference from the @xtremax/pigeon npm package, plus sending notifications from server code. Use whenever the user asks for a notification bell, in-app notifications, a notification inbox or centre, an unread badge, "notify the user when X happens", or mentions Pigeon or the Communication connector, even if they do not name the package. Do not build a custom bell, a notifications table or polling code - this skill replaces all of that.
4
+ user-invokable: true
5
+ ---
6
+
7
+ # Add Pigeon Notifications
8
+
9
+ Gives an app a notification bell and the code to send notifications, using **Pigeon** and its npm package `@xtremax/pigeon`. Pigeon stores the notifications, the read state and each person's on/off choice. The app stores nothing.
10
+
11
+ This skill ships inside the package. `npx @xtremax/pigeon add-skill` copies it into the app's `.claude/skills/` (and `.kiro/skills/`); run that again after upgrading the package to get the newer skill. It is self-contained: it assumes you have none of Pigeon's own repository.
12
+
13
+ ## What you are building
14
+
15
+ ```
16
+ browser: the bell -> the app's own route: /api/communication/inbox/* -> Pigeon
17
+ (from the package) (one file, from the package's handler) (holds the notifications)
18
+
19
+ the app's server code -> communication.messages.send(...) -> Pigeon -> the person's bell
20
+ ```
21
+
22
+ Three small pieces, all from one package:
23
+
24
+ | Piece | Where | From |
25
+ |---|---|---|
26
+ | The route the bell calls | `app/api/communication/inbox/[...path]/route.ts` | `@xtremax/pigeon/inbox` |
27
+ | The bell | the app's header | `@xtremax/pigeon/inbox-react` |
28
+ | Sending | wherever something happens that a person should know | `@xtremax/pigeon/client` |
29
+
30
+ A bell shows a person two things: what **this app** sent them, and the organisation's **announcements**. Another app's notifications never appear in it. There is nothing to configure for that.
31
+
32
+ ## The rules
33
+
34
+ These are what keeps the integration safe. Each has a reason; do not trade one away for convenience.
35
+
36
+ 1. **The key stays on the server.** `COMMUNICATION_API_KEY` is a secret that can read anybody's notifications. Never give it a `NEXT_PUBLIC_` name, never send it to the browser, never log it.
37
+ 2. **The browser never calls Pigeon.** It calls the app's own route, and that route decides who the person is. This is why a person can only ever see their own notifications.
38
+ 3. **The person comes from the app's own session only.** `resolvePerson` reads the signed-in user on the server. Never read a person id from the request's path, query or body: anyone could put another person's id there.
39
+ 4. **One id per person, everywhere.** The id you send to (`personId`) must be the exact string `resolvePerson` returns. Use one helper for both (Step 4). A mismatch is the most common mistake: the message is delivered and the bell stays empty.
40
+ 5. **Do not build what the package has.** No custom bell, panel, unread counter, polling hook, notifications collection or "mark as read" endpoint. No `CollectionList` for notifications: they are not a collection of this app.
41
+ 6. **A notification must never break the action that caused it.** If sending fails, log it and carry on (Step 6).
42
+
43
+ The package's preference dialog uses Mantine's `Switch` internally. That is inside the package, not app code, so the Buildpad-First rule does not apply to it. Do not wrap or replace it.
44
+
45
+ ## Before you start
46
+
47
+ **1. The app must have** Next.js (App Router), React 19, Mantine 8 (`@mantine/core`, `@mantine/hooks`) and `@tabler/icons-react`. Every Buildpad starter has them. Check `package.json`. If the app is not on React and Mantine, read `references/inbox-contract.instructions.md`: the route still works, and you build the bell against its six operations.
48
+
49
+ **2. You need a key and Pigeon's address.** Look in this order, and stop at the first that has them:
50
+
51
+ - `get_project_detail` (the platform MCP tool): if `connectors[]` has `communication`, its variables are `COMMUNICATION_API_KEY` and `COMMUNICATION_URL`.
52
+ - `.env.local` already has both.
53
+ - Otherwise **ask the user for both**. Do not invent them and do not continue without them. Tell the user how an operator gets them, in Pigeon's console:
54
+ 1. Open the organisation's tenant and add a **sub-tenant** for this app (this is what gives the app a bell of its own).
55
+ 2. Switch to that sub-tenant, then **Delivery › API clients › New API client**. The key is shown once.
56
+ 3. Under **Channels**, the in-app channel must be switched on for the tenant.
57
+
58
+ **3. The package must be installable.** If `pnpm add @xtremax/pigeon` answers 404, the package is not published yet or the registry is wrong. Stop and tell the user. Do not recreate the bell by hand.
59
+
60
+ ## Step 1: Install
61
+
62
+ ```bash
63
+ pnpm add @xtremax/pigeon
64
+ ```
65
+
66
+ Version 0.3.1 or later. No component files are copied into the app.
67
+
68
+ If you are reading this skill from `node_modules/@xtremax/pigeon/skills/`, the package is already installed: run `npx @xtremax/pigeon add-skill` so the skill is in the project for the next session, and carry on.
69
+
70
+ ## Step 2: Environment
71
+
72
+ `.env.local`:
73
+
74
+ ```env
75
+ COMMUNICATION_API_KEY=<the key>
76
+ COMMUNICATION_URL=<Pigeon's address, for example https://pigeon.example.com>
77
+ ```
78
+
79
+ For a deployed app on AWS Amplify, do both of these, or the running app will not see the variables:
80
+
81
+ - Set the two variables on the Amplify app (the `amplify_set_env_vars` platform tool, or the Amplify console).
82
+ - Add them to `amplify.yml`, in the build commands, beside the other server-only variables:
83
+
84
+ ```yaml
85
+ - echo "COMMUNICATION_API_KEY=$COMMUNICATION_API_KEY" >> .env.production
86
+ - echo "COMMUNICATION_URL=$COMMUNICATION_URL" >> .env.production
87
+ ```
88
+
89
+ ## Step 3: Check the key works
90
+
91
+ Run this once, from the app's folder. It prints where the key sits and which channels it can use. It never prints the key.
92
+
93
+ ```bash
94
+ node --env-file=.env.local --input-type=module -e "import { createCommunicationClient } from '@xtremax/pigeon/client'; const c = createCommunicationClient(); console.log(await c.ping()); console.log(await c.channels.list());"
95
+ ```
96
+
97
+ Read the output:
98
+
99
+ - `scopes` must include `inbox:read` and `inbox:write`. If not, the key is an old one: ask the user for a new key.
100
+ - `subKey` is the app's sub-tenant, or `null` for a key at tenant level. Either works. With `null`, the app shares a bell with every other tenant-level app.
101
+ - In the channel list, find the entry with `medium: 'in_app'` and `ready: true`. Its `key` is the channel you send on, usually `in-app`. If there is none, the operator has to switch the in-app channel on; nothing can be sent until then.
102
+
103
+ ## Step 4: One helper for the person's id
104
+
105
+ ```ts
106
+ // lib/communication/person.ts
107
+ /** The id Pigeon knows a person by: their email, in lower case. Used for reading and for sending. */
108
+ export const personIdOf = (email: string): string => email.trim().toLowerCase();
109
+ ```
110
+
111
+ Use the lower-cased email unless the user tells you otherwise. It is the same in every app of the organisation, so an announcement read in one app's bell is read in the others.
112
+
113
+ ## Step 5: The route
114
+
115
+ One file. It needs no other code.
116
+
117
+ ```ts
118
+ // app/api/communication/inbox/[...path]/route.ts
119
+ import { createInboxHandler } from '@xtremax/pigeon/inbox';
120
+ import { personIdOf } from '@/lib/communication/person';
121
+ import { createClient } from '@/lib/supabase/server';
122
+
123
+ export const dynamic = 'force-dynamic';
124
+
125
+ const handler = createInboxHandler({
126
+ // Who is signed in, from this app's own session. null means nobody.
127
+ resolvePerson: async () => {
128
+ const supabase = await createClient();
129
+ const { data } = await supabase.auth.getUser();
130
+ return data.user?.email ? personIdOf(data.user.email) : null;
131
+ },
132
+ // The browser is never told why something failed. This is where you see it.
133
+ onError: (error) => console.error('[communication-inbox]', error),
134
+ });
135
+
136
+ export { handler as GET, handler as POST, handler as PUT };
137
+ ```
138
+
139
+ - `createClient` from `@/lib/supabase/server` is how a Buildpad starter reads the signed-in user, on an Engine project and on a Supabase project alike. If the app signs people in another way, read the user from that session instead. The rule is only: from the server's session, never from the request.
140
+ - In an app with locale routes (`app/[lang]/...`), the route still lives at `app/api/...`, not under `[lang]`.
141
+ - **Check the middleware.** A signed-out call to this route must get the handler's own `401` JSON, not a redirect to a login page. If the app's middleware redirects `/api/*` requests, exclude `/api/communication/inbox`.
142
+ - The handler reads the key and the address on the first request. A missing one answers `503 unavailable` and never stops the build.
143
+
144
+ ## Step 6: The bell
145
+
146
+ Import the stylesheet once, in the root layout, **after** Mantine's:
147
+
148
+ ```tsx
149
+ // app/layout.tsx
150
+ import '@mantine/core/styles.css';
151
+ import '@xtremax/pigeon/inbox-react/styles.css';
152
+ ```
153
+
154
+ Put the bell in the header that signed-in people see. In a Buildpad starter that is `<AppShell.Header>` in `components/layout/AuthenticatedShell.tsx`:
155
+
156
+ ```tsx
157
+ import { InboxProvider, NotificationBell } from '@xtremax/pigeon/inbox-react';
158
+
159
+ <InboxProvider>
160
+ <NotificationBell />
161
+ </InboxProvider>
162
+ ```
163
+
164
+ - It must render inside `MantineProvider`, and only for signed-in people.
165
+ - It follows the app's Mantine theme and colour scheme. Do not restyle it.
166
+ - In an app with locale routes, pass the locale: `<InboxProvider locale={lang}>`. For a language other than English pass `labels` too; the list of labels is in `references/inbox-contract.instructions.md`.
167
+ - Render it once per page: one `InboxProvider` with one `NotificationBell`. Each provider asks for the count on its own, so two of them double the calls.
168
+
169
+ ## Step 7: Send notifications
170
+
171
+ One module makes the client, lazily, so a missing key cannot break the build:
172
+
173
+ ```ts
174
+ // lib/communication/client.ts
175
+ import { createCommunicationClient, type CommunicationClient } from '@xtremax/pigeon/client';
176
+
177
+ let client: CommunicationClient | undefined;
178
+ /** Server only. Reads COMMUNICATION_API_KEY and COMMUNICATION_URL. */
179
+ export const communication = (): CommunicationClient => (client ??= createCommunicationClient());
180
+ ```
181
+
182
+ One function sends, and never throws:
183
+
184
+ ```ts
185
+ // lib/communication/notify.ts
186
+ import { deriveIdempotencyKey } from '@xtremax/pigeon';
187
+ import { CommunicationError } from '@xtremax/pigeon/client';
188
+ import { communication } from './client';
189
+ import { personIdOf } from './person';
190
+
191
+ /** The in-app channel's key, as `channels.list()` named it in Step 3. */
192
+ const IN_APP_CHANNEL = 'in-app';
193
+
194
+ export interface Notification {
195
+ /** The recipient's email. */
196
+ to: string;
197
+ /** Plain text, at most 120 characters. */
198
+ title: string;
199
+ /** Plain text, at most 1800 characters. */
200
+ body: string;
201
+ /** What makes this notification unique: the event, the record and the recipient. Sent twice with the same parts, it arrives once. */
202
+ dedupe: Array<string | number>;
203
+ }
204
+
205
+ /** Sends one in-app notification. Answers whether Pigeon accepted it. Never throws: a notification must not break the action that caused it. */
206
+ export async function notify({ to, title, body, dedupe }: Notification): Promise<boolean> {
207
+ try {
208
+ const personId = personIdOf(to);
209
+ const message = await communication().messages.send({
210
+ templateKey: 'generic.notification',
211
+ channel: IN_APP_CHANNEL,
212
+ recipient: { personId },
213
+ variables: { title: title.slice(0, 120), body: body.slice(0, 1800) },
214
+ idempotencyKey: await deriveIdempotencyKey(...dedupe, personId),
215
+ });
216
+ // `failed` here is usually the person's own choice: they switched this app's notifications off.
217
+ return message.state !== 'failed';
218
+ } catch (error) {
219
+ console.error('[communication] notification not sent:', error instanceof CommunicationError ? error.code : error);
220
+ return false;
221
+ }
222
+ }
223
+ ```
224
+
225
+ Call it from server code only (an API route or a server action), after the action itself has succeeded:
226
+
227
+ ```ts
228
+ await notify({
229
+ to: member.email,
230
+ title: 'You were added to Finance',
231
+ body: 'Open Organisations to see your unit.',
232
+ dedupe: ['unit-member-added', unit.id],
233
+ });
234
+ ```
235
+
236
+ What to know when choosing what to send:
237
+
238
+ - **Ask the user which events should notify whom** if they have not said. Do not notify on everything.
239
+ - **Do not notify the person who did the action** about their own action.
240
+ - **`generic.notification`** is a template every tenant has: a title and a body, plain text. Markup is shown as text.
241
+ - **A notification that opens a page** needs a template with a link, which an operator adds under **Templates** in Pigeon's console (an in-app variant with a `link`). Use its key as `templateKey` and pass its variables. Only absolute `https://` links open. You cannot create a template from the app: ask the user.
242
+ - **Many recipients:** call `notify` once per person. For "everyone in the organisation", that is an announcement, which an operator sends from Pigeon's console; do not loop over all users.
243
+ - **`dedupe`** must name the event and the record; the recipient is added for you. Sent again with the same parts, the notification arrives once, which is what makes a retry safe. So when the same thing can truly happen twice (added, removed, added again), include what tells the two apart, such as the id of the membership record. The same parts with different text are refused.
244
+
245
+ ## Step 8: Tests
246
+
247
+ Add these. They are quick, and the first group needs no key.
248
+
249
+ **Playwright, signed out** (the route protects itself):
250
+
251
+ ```ts
252
+ test('the inbox route refuses a signed-out caller and a foreign origin', async ({ request }) => {
253
+ const signedOut = await request.get('/api/communication/inbox/summary');
254
+ expect(signedOut.status()).toBe(401);
255
+ expect((await signedOut.json()).error.code).toBe('not_signed_in');
256
+
257
+ const foreign = await request.post('/api/communication/inbox/read-all', { headers: { origin: 'https://evil.example' }, data: {} });
258
+ expect(foreign.status()).toBe(403);
259
+
260
+ expect((await request.get('/api/communication/inbox/everyone')).status()).toBe(404);
261
+ });
262
+ ```
263
+
264
+ **Playwright, signed in** (with the app's existing test sign-in): the bell is in the header and opens.
265
+
266
+ ```ts
267
+ const bell = page.getByRole('button', { name: /^Notifications/ });
268
+ await expect(bell).toBeVisible();
269
+ await bell.click();
270
+ await expect(page.getByRole('dialog').getByRole('heading', { name: 'Notifications' })).toBeVisible();
271
+ ```
272
+
273
+ **Vitest:** `personIdOf(' Ana@Acme.com ')` is `'ana@acme.com'`; and `notify` answers `false` without throwing when the client throws (mock `./client`).
274
+
275
+ ## Step 9: Verify
276
+
277
+ - [ ] `pnpm build` and the type check pass.
278
+ - [ ] Signed out, `GET /api/communication/inbox/summary` answers `401` with `{"error":{"code":"not_signed_in",...}}`, as JSON and not as a redirect.
279
+ - [ ] Signed in, the same call answers `200` with `unreadCount`, and the bell shows in the header.
280
+ - [ ] Trigger a `notify`. The badge appears at the bell's next check, within 60 seconds. Opening the bell shows the notification; selecting it marks it read.
281
+ - [ ] `grep -rn "COMMUNICATION_API_KEY" app components lib` shows no file with `'use client'` and no `NEXT_PUBLIC_` name.
282
+ - [ ] No custom bell, notifications collection or polling code was added.
283
+ - [ ] The app's docs say where notifications are sent from and which two variables a deployment needs.
284
+
285
+ ## When it does not work
286
+
287
+ | What you see | Cause |
288
+ |---|---|
289
+ | `pnpm add` answers 404 | The package is not published, or the registry is not npm's. Stop and tell the user. |
290
+ | The bell's calls answer `503 unavailable` | The key or the address is missing or wrong, or `resolvePerson` threw. The server log has the real error after `[communication-inbox]`. On Amplify, the two `amplify.yml` lines are usually what is missing. |
291
+ | The bell's calls answer `401 not_signed_in` while signed in | `resolvePerson` returned `null`: the session is not read in the route. |
292
+ | The bell's calls return HTML, or the bell never shows a count | The middleware redirects the route to a login page. Exclude `/api/communication/inbox`. |
293
+ | A send throws `channel_unavailable` | The in-app channel is not switched on for the tenant, or `IN_APP_CHANNEL` is not the key that `channels.list()` named. |
294
+ | A send throws `forbidden` | The key lacks a scope. Ask for a new key. |
295
+ | The message is delivered but the bell is empty | The `personId` sent to differs from what `resolvePerson` returns, or the key used for sending sits in another sub-tenant than the key the route uses. Use one key and `personIdOf` on both sides. |
296
+ | `notify` answers `false` and nothing is logged | The message was refused by the person's own choice: they switched this app's notifications off in the bell's preferences. This is correct. |
297
+ | The list inside the bell looks unstyled | The stylesheet import of Step 6 is missing. |
298
+ | An error says `MantineProvider` was not found | The bell is rendered outside `MantineProvider`. |
299
+
300
+ ## Reference
301
+
302
+ `references/inbox-contract.instructions.md`: every option of `InboxProvider` and `createInboxHandler`, the list of labels for translation, the six operations of the route with their answers and error codes, calling the inbox directly from server code, and what to do in an app without React or Mantine.
@@ -0,0 +1,208 @@
1
+ # Pigeon inbox: the full contract
2
+
3
+ Read this when the steps in `SKILL.md` are not enough: translating the bell, placing the full list on a page of its own, reading the inbox from server code, or building a bell without React and Mantine.
4
+
5
+ ## Contents
6
+
7
+ - [The package's entries](#the-packages-entries)
8
+ - [`InboxProvider`, `NotificationBell`, `InboxFullList`](#inboxprovider-notificationbell-inboxfulllist)
9
+ - [Labels](#labels)
10
+ - [`createInboxHandler`](#createinboxhandler)
11
+ - [The six operations of the route](#the-six-operations-of-the-route)
12
+ - [Error codes](#error-codes)
13
+ - [How the bell behaves](#how-the-bell-behaves)
14
+ - [Reading the inbox from server code](#reading-the-inbox-from-server-code)
15
+ - [An app without React or Mantine](#an-app-without-react-or-mantine)
16
+
17
+ ## The package's entries
18
+
19
+ | Import | Runs | Holds |
20
+ |---|---|---|
21
+ | `@xtremax/pigeon/client` | server | `createCommunicationClient`, `CommunicationError` |
22
+ | `@xtremax/pigeon/inbox` | server | `createInboxHandler`, `createInboxClient` |
23
+ | `@xtremax/pigeon/inbox-react` | browser | `InboxProvider`, `NotificationBell`, `InboxFullList`, `DEFAULT_INBOX_LABELS` |
24
+ | `@xtremax/pigeon/inbox-react/styles.css` | browser | the bell's stylesheet |
25
+ | `@xtremax/pigeon/events` | server | `createEventHandler`, `verifyEventSignature` (delivery events; not needed for the bell) |
26
+ | `@xtremax/pigeon` | server | all server entries, plus `deriveIdempotencyKey`, `newIdempotencyKey` |
27
+
28
+ The server entries hold the key. Imported into a browser bundle they resolve to a stub that throws `server_only`. `inbox-react` holds no key and calls only the app's own route.
29
+
30
+ The names in the code say `Communication` (`createCommunicationClient`, `COMMUNICATION_API_KEY`). That is Pigeon's name on the Buildpad platform, where it is the Communication connector. It is the same service.
31
+
32
+ ## `InboxProvider`, `NotificationBell`, `InboxFullList`
33
+
34
+ `InboxProvider` props, all optional:
35
+
36
+ | Prop | Default | Use |
37
+ |---|---|---|
38
+ | `apiBase` | `/api/communication/inbox` | Where the app mounted the route. A path on the app's own origin only: an absolute URL is refused. |
39
+ | `labels` | English | Replaces any visible or spoken text. See [Labels](#labels). |
40
+ | `locale` | the browser's | Formats times, for example `'id'` or `'en-GB'`. |
41
+ | `onViewAll` | opens the full list in a dialog | Call your router to open a page of your own instead. |
42
+ | `onOpenPreferences` | opens the preference dialog | Open a settings page of your own instead. |
43
+ | `openLink` | a new tab, `noopener,noreferrer` | Open a notification's link your own way, for example in the same tab. |
44
+
45
+ `labels` is a plain object, so it can be passed from a server component. The three callbacks are functions: to use one, render `InboxProvider` from a client component (`'use client'`).
46
+
47
+ `NotificationBell` takes `size` (pixels, never under 44). It must be inside `InboxProvider`.
48
+
49
+ `InboxFullList` is the full, paged list. Use it with `onViewAll` to give notifications a page of their own. It needs an `InboxProvider` above it, so put the provider around the whole signed-in layout: the header's bell and the page's list then share one, and the count is asked for once.
50
+
51
+ ```tsx
52
+ 'use client';
53
+ import { InboxFullList, InboxProvider, NotificationBell } from '@xtremax/pigeon/inbox-react';
54
+ import { useRouter } from 'next/navigation';
55
+
56
+ // around the signed-in layout: the header with the bell, and the pages
57
+ const router = useRouter();
58
+ <InboxProvider onViewAll={() => router.push('/notifications')}>
59
+ <header><NotificationBell /></header>
60
+ {children}
61
+ </InboxProvider>
62
+
63
+ // on app/(authenticated)/notifications/page.tsx
64
+ <InboxFullList maxHeight="70vh" />
65
+ ```
66
+
67
+ ## Labels
68
+
69
+ Pass any of these in `labels`. `{n}` and `{label}` are filled in.
70
+
71
+ | Key | English default |
72
+ |---|---|
73
+ | `bell` | Notifications |
74
+ | `bellWithCount` | {label}, {n} unread |
75
+ | `heading` | Notifications |
76
+ | `tabAll` | All |
77
+ | `tabUnread` | Unread |
78
+ | `tabUnreadWithCount` | Unread ({n}) |
79
+ | `markAllRead` | Mark all as read |
80
+ | `viewAll` | View all |
81
+ | `preferences` | Notification preferences |
82
+ | `emptyAll` | You have no notifications. |
83
+ | `emptyUnread` | You have no unread notifications. |
84
+ | `loading` | Loading notifications |
85
+ | `loadError` | Notifications could not be loaded. |
86
+ | `retry` | Try again |
87
+ | `actionError` | That did not work. Try again. |
88
+ | `dismiss` | Dismiss |
89
+ | `close` | Close |
90
+ | `unread` | Unread |
91
+ | `announcement` | Announcement |
92
+ | `loadMore` | Show older notifications |
93
+ | `newOne` | 1 new notification |
94
+ | `newMany` | {n} new notifications |
95
+ | `fullListTitle` | All notifications |
96
+ | `preferencesTitle` | Notification preferences |
97
+ | `preferenceToggle` | Receive in-app notifications |
98
+ | `preferenceOn` | New notifications from this app appear here. |
99
+ | `preferenceOff` | New notifications from this app do not appear here. The ones you already have stay. Other apps are not affected. |
100
+ | `preferenceLoadError` | Your preference could not be loaded. |
101
+ | `preferenceSaveError` | Your choice was not saved. Try again. |
102
+
103
+ The titles and bodies of notifications are not labels: they are written by whoever sent them, in the language they chose.
104
+
105
+ ## `createInboxHandler`
106
+
107
+ ```ts
108
+ createInboxHandler(options): (request: Request) => Promise<Response>
109
+ ```
110
+
111
+ | Option | Default | Use |
112
+ |---|---|---|
113
+ | `resolvePerson` (required) | | `(request) => string \| null`, sync or async. The signed-in person's id from the app's own session. `null` or an empty string means nobody. At most 128 characters. |
114
+ | `client` | made on the first request from `COMMUNICATION_API_KEY` and `COMMUNICATION_URL` | Pass `createInboxClient({ apiKey, baseUrl })` to use other settings. |
115
+ | `basePath` | `/api/communication/inbox` | Set it when the route file is somewhere else. It must match `apiBase` on `InboxProvider`. |
116
+ | `allowedOrigins` | the request's own origin | Origins that may send `POST` and `PUT`. Needed only when pages are served from another origin than the route. |
117
+ | `timeoutMs` | 8000 | Bounds each call to Pigeon. |
118
+ | `onError` | | Told about every failure answered as `unavailable`. Log it: the browser is never told the cause. |
119
+
120
+ The handler takes a standard `Request` and answers a standard `Response`, so it works in any server that speaks the Fetch API, not only Next.js. Export it for `GET`, `POST` and `PUT`.
121
+
122
+ ## The six operations of the route
123
+
124
+ All paths are under the base path. Every answer is JSON with `Cache-Control: no-store`. A success is `{ "data": ... }`; a failure is `{ "error": { "code", "message", "field"? } }`.
125
+
126
+ | Method and path | Request | `data` in the answer |
127
+ |---|---|---|
128
+ | `GET /summary` | | `{ unreadCount, newestUnreadAt, pollSeconds }` |
129
+ | `GET /notifications` | query `limit` (1 to 100, default 20), `cursor`, `unread=true` | `{ notifications: [...], nextCursor, unreadCount }` |
130
+ | `POST /notifications/{id}/read` | empty JSON body | `{ notification, unreadCount }` |
131
+ | `POST /read-all` | `{ "upTo"?: ISO time }` | `{ marked, remaining, unreadCount }`; call again while `remaining > 0` |
132
+ | `GET /preference` | | `{ state, changedAt, delivers }` |
133
+ | `PUT /preference` | `{ "state": "opted_in" \| "opted_out" }` | `{ state, changedAt, delivers }` |
134
+
135
+ A notification:
136
+
137
+ ```ts
138
+ interface InboxNotification {
139
+ id: string;
140
+ kind: 'notification' | 'announcement';
141
+ title: string; // plain text; never render it as markup
142
+ body: string; // plain text
143
+ link: string | null; // absolute https, or null
144
+ source: { type: 'xapp' | 'tenant'; name: string };
145
+ createdAt: string; // ISO time
146
+ read: boolean;
147
+ readAt: string | null;
148
+ }
149
+ ```
150
+
151
+ - `nextCursor` is `null` on the last page. Pass it back as `cursor` unchanged.
152
+ - `state` is `opted_in`, `opted_out` or `not_set`; `delivers` says whether new notifications reach the person now.
153
+ - `POST` and `PUT` must come from the app's own pages: the handler checks `Sec-Fetch-Site: same-origin` or the `Origin` header.
154
+
155
+ ## Error codes
156
+
157
+ | Code | Status | Meaning |
158
+ |---|---|---|
159
+ | `not_signed_in` | 401 | `resolvePerson` returned nobody. The bell stops asking, silently. |
160
+ | `not_permitted` | 403 | A `POST` or `PUT` from another origin, or Pigeon refused the key for this call. |
161
+ | `not_found` | 404 | No such operation, or a notification that is not this person's or not this app's. |
162
+ | `invalid_input` | 422 | A value from the browser was refused; `field` names it. |
163
+ | `rate_limited` | 429 | Too many calls. |
164
+ | `unavailable` | 503 | Everything else: a wrong or revoked key, a timeout, Pigeon not reachable. The cause goes to `onError` only. |
165
+
166
+ Errors thrown by `createCommunicationClient` and `createInboxClient` are `CommunicationError` with `code`, `status`, `requestId` and `retryable`. Codes you may meet when sending: `channel_unavailable`, `validation_failed` (a missing or too long variable, an unknown template), `forbidden`, `invalid_credentials`, `rate_limited`, `idempotency_conflict`, `not_configured`, `timeout`, `network_error`.
167
+
168
+ ## How the bell behaves
169
+
170
+ - **Updates.** It asks for the count every 60 seconds (Pigeon's setting, never under 15). It pauses while the page is hidden and asks at once when it is visible again. After failures it waits longer, up to 5 minutes. There is no realtime push.
171
+ - **Outage.** It keeps the last count and the page keeps working. A list that cannot load shows "Try again".
172
+ - **Selecting a notification** marks it read, and opens its link when it has an `https` one.
173
+ - **"Mark all as read"** covers what this bell shows, never another app's notifications.
174
+ - **Announcements** are one copy per person: read in one app's bell, they are read in every bell.
175
+ - **The preference** is this app's own. Switching it off stops this app's notifications for the person; other apps and announcements go on.
176
+ - **Nothing is kept in the browser's storage.**
177
+
178
+ ## Reading the inbox from server code
179
+
180
+ For a count in a server-rendered page, a digest email or a test. Each call takes the person id first.
181
+
182
+ ```ts
183
+ import { createInboxClient } from '@xtremax/pigeon/inbox';
184
+ import { communication } from '@/lib/communication/client';
185
+
186
+ const inbox = createInboxClient(communication()); // shares the key and settings
187
+
188
+ const { unreadCount } = await inbox.summary(personId);
189
+ const page = await inbox.list(personId, { limit: 20, unreadOnly: true });
190
+ await inbox.markRead(personId, page.notifications[0].id);
191
+ await inbox.markAllRead(personId);
192
+ const preference = await inbox.getPreference(personId);
193
+ await inbox.setPreference(personId, 'opted_out');
194
+ ```
195
+
196
+ These throw `CommunicationError` on failure. The person id here is trusted, so it must come from your own session or data, never from a request.
197
+
198
+ ## An app without React or Mantine
199
+
200
+ The route is the same: mount `createInboxHandler` on a catch-all route of the app's server. Then build the bell with the app's own components against the six operations above:
201
+
202
+ 1. Ask `GET /summary` when the page loads, then every `pollSeconds`. Stop on `401`. Wait longer after a failure.
203
+ 2. Show `unreadCount` on the bell (`99+` above 99).
204
+ 3. On open, `GET /notifications`. Show `title` and `body` as text. Offer `nextCursor` as "show older".
205
+ 4. On select, `POST /notifications/{id}/read`, and open `link` in a new tab with `noopener,noreferrer` only when it starts with `https://`.
206
+ 5. Offer "mark all as read" (`POST /read-all`, repeated while `remaining > 0`) and the on/off choice (`GET` and `PUT /preference`).
207
+
208
+ Send requests with `credentials: 'same-origin'`. Never call Pigeon from the browser and never put the key in the page.