@horizonmedia/blu-sdk 0.0.0-stage → 1.8.3

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/README.md CHANGED
@@ -1,3 +1,553 @@
1
- # Temporary Holding Version
1
+ # @blu/sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Everything a team needs to build a module for Blu: its front end, its API, and running it on its own. It's published on npm as **`@horizonmedia/blu-sdk`**; your code imports it as **`@blu/sdk`**.
4
+
5
+ | Import | For |
6
+ |---|---|
7
+ | `@blu/sdk` | your module's **front end**: who the user is, their client, the theme, routing, calling your API |
8
+ | `@blu/sdk/frame` | front ends mounted **isolated**, in their own iframe (`mountIsolated`) |
9
+ | `@blu/sdk/server` | your module's **API**: verifying Blu callers, route policies, CORS, tools for the Blu Concierge |
10
+ | `@blu/sdk/dev` | running your module **on its own**, outside the Blu shell (`mountStandalone`) |
11
+
12
+ A Next.js app adds **[`@blu/next-adapter`](../next-adapter/README.md)** on top of this.
13
+
14
+ **Contents:** [Install](#install) · [Front end](#front-end-blusdk) · [Isolated modules](#isolated-modules-blusdkframe) · [Running on its own](#running-on-its-own-blusdkdev) · [Your API](#your-api-blusdkserver) · [Checking your integration](#checking-your-integration-blu-check) · [Versions](#versions)
15
+
16
+ ---
17
+
18
+ ## Install
19
+
20
+ The package is public on npm (registry.npmjs.org), so installing needs no `.npmrc`, login or token: not on your machine, in CI or in Docker builds.
21
+
22
+ **Moving from GitHub Packages** (versions up to 1.8.2 were published there): remove the `@horizonmedia:registry=https://npm.pkg.github.com` line from your `.npmrc`, and any token you set up for it, then reinstall so your lockfile points at registry.npmjs.org. `blu check` warns while the line is still there.
23
+
24
+ **Add it under the name your code imports:**
25
+
26
+ ```sh
27
+ npm install @blu/sdk@npm:@horizonmedia/blu-sdk@^1.8.3
28
+ ```
29
+
30
+ which writes:
31
+
32
+ ```json
33
+ "dependencies": {
34
+ "@blu/sdk": "npm:@horizonmedia/blu-sdk@^1.8.3"
35
+ }
36
+ ```
37
+
38
+ The API part needs nothing else. The front-end parts need `react` and `react-dom` 18.2+ or 19, which your app already has.
39
+
40
+ **React 18:** fine for building and for running your app on its own. Inside Blu, though, an inline (federated) module runs on the **shell's React 19**: the page shares one copy of React. React 18 code generally works there, but avoid what React 19 removed:
41
+ - `ReactDOM.render` / `hydrate`;
42
+ - string refs;
43
+ - legacy context (`contextTypes`);
44
+ - `defaultProps` on function components;
45
+ - `findDOMNode`.
46
+
47
+ `blu check` warns when a module declares React 18. Test the module in the shell before you release it. Isolated (iframe) modules use their own React.
48
+
49
+ ---
50
+
51
+ ## Front end: `@blu/sdk`
52
+
53
+ Inside Blu, your module's page is rendered by the Blu shell. These hooks read what the shell provides: the same values whether your module is mounted inline, in an iframe, or running on its own.
54
+
55
+ ```tsx
56
+ import { useEffect, useState } from "react";
57
+ import { useApiClient, useClient, useTheme, useUser } from "@blu/sdk";
58
+
59
+ export default function Page() {
60
+ const user = useUser(); // { id, email, displayName, provider }
61
+ const client = useClient(); // { id, name }: the client chosen in Blu's header
62
+ const theme = useTheme(); // Blu's colours, radius and font
63
+ const api = useApiClient(); // your OWN API, with a token for it
64
+
65
+ const [rows, setRows] = useState([]);
66
+ useEffect(() => { api.get("/api/campaigns").then(setRows); }, [api, client.id]);
67
+ return <h1 style={{ color: theme.primary }}>Hello {user.displayName} · {client.name}</h1>;
68
+ }
69
+ ```
70
+
71
+ | Hook | Gives you |
72
+ |---|---|
73
+ | `useUser()` | the signed-in person |
74
+ | `useClient()` | the active client. Your page re-renders when it changes; refetch then. |
75
+ | `useRoles()` | the user's Blu roles on that client (`viewer`, `analyst`, `owner`) |
76
+ | `useTheme()` | Blu's theme tokens, so your module matches the shell |
77
+ | `useEnvironment()` | `{ stage, apiBaseUrl, moduleId }`: which environment, and **your** API's URL in it |
78
+ | `useShell()` | who's hosting you: `kind`, `label`, `mount` (`inline`, `isolated` or `standalone`), and who chose the client |
79
+ | `useApiClient()` | `get(path)` / `post(path, body)` to your own API with a token for it; non-2xx responses throw `ApiError` (`status`, `body`) |
80
+ | `useAuthToken()` | the token getter itself, for a client library of your own |
81
+ | `useRoute()` | where the user is in your module: `path`, `search`, and `navigate(path, { replace? })` |
82
+ | `useEventBus()` | `emit(type, payload)` and `on(type, handler)` on the shell's event bus |
83
+ | `useHost()` | all of the above in one object |
84
+ | `useOptionalHost()` | the same, or `null` when your code runs outside Blu (handy in code shared with a standalone app) |
85
+
86
+ **Tokens.** `useApiClient()` sends a short-lived token issued for **your module's API only**, renewed automatically. It refuses to send it to any other address.
87
+
88
+ **Routing.** Blu keeps the URL. `useRoute().path` is your module-relative path (`/campaigns/acme-2`), shown in the address bar under your module's place in Blu (`/pacing/campaigns/acme-2`). Navigate with `navigate()`, and the back button and deep links just work. List the paths other people may link to (the Concierge's answers, for example) under `routes` in your `blu.module.json`.
89
+
90
+ **Sign in with Blu**, for your own standalone app: `beginBluSignIn({ hostApi, clientId, redirectUri })` sends the user to Blu's sign-in page, and `completeBluSignIn()` finishes it when they come back. The token you get is for your module only. (`@blu/sdk/dev` does this for you.)
91
+
92
+ ---
93
+
94
+ ## Isolated modules: `@blu/sdk/frame`
95
+
96
+ A module that runs on its own site, in a sandboxed iframe inside the Blu shell (typically a partner's), uses the same hooks. Only the entry point changes:
97
+
98
+ ```tsx
99
+ // frame.tsx: the page the Blu shell loads in the iframe
100
+ import { mountIsolated } from "@blu/sdk/frame";
101
+ import Page from "./Page";
102
+
103
+ mountIsolated({ Page });
104
+ ```
105
+
106
+ - **The bridge.** `mountIsolated` connects to the Blu shell over `postMessage`: context, tokens for your API, resizing and routing. It accepts messages only from the shell origins listed in your site's `./deployment.json` (`hostOrigins`).
107
+ - **Your own router?** Use `connectToBluShell(hostOrigins)` directly: it gives you the same context the hooks provide, and the connection's `reportRoute()` keeps Blu's address bar in step with your router.
108
+ - **What the frame can't see.** It never sees Blu's page, session or other modules, and receives tokens for your API only.
109
+
110
+ ---
111
+
112
+ ## Running on its own: `@blu/sdk/dev`
113
+
114
+ Run your module's unchanged page outside Blu, on its own origin, for development or as a standalone app:
115
+
116
+ ```tsx
117
+ // standalone.tsx: your standalone index.html's entry
118
+ import { mountStandalone } from "@blu/sdk/dev";
119
+ import Page from "./Page";
120
+
121
+ mountStandalone({ Page });
122
+ ```
123
+
124
+ It reads two files next to the page at runtime, so one build serves every environment:
125
+
126
+ - **`standalone.config.json`:** `{ "moduleId": "pacing", "title": "Pacing", "auth": { "team": { "label": "Sign in" }, "blu": { "label": "Sign in with Blu" } } }`. Each sign-in option is optional.
127
+ - **`deployment.json`:** `{ "stage": "dev", "apiBaseUrl": "…", "teamAuthUrl": "…", "bluHostApi": "…" }`
128
+
129
+ It provides the same context the Blu shell does, so `useUser()`, `useApiClient()` and the rest behave the same.
130
+
131
+ ---
132
+
133
+ ## Your API: `@blu/sdk/server`
134
+
135
+ Server-side helpers for your module's API, called by Blu users, other services and the Blu Concierge. On every request they answer two questions:
136
+
137
+ 1. **Who is calling?** A verified, normalized caller (the *principal*), whichever way they authenticated.
138
+ 2. **May they do this?** A per-route policy that **your team** writes.
139
+
140
+ It can only **verify** Blu tokens; it can never create them. Blu's signing keys never leave Blu.
141
+
142
+ Works with Express, and with Web-standard handlers (Next.js route handlers, Hono, workers).
143
+
144
+ ### Quick start (Express)
145
+
146
+ ```js
147
+ import express from "express";
148
+ import { bluPlatformAuth, corsAllowlist, createModuleAuth } from "@blu/sdk/server";
149
+
150
+ const BLU_ISSUER = process.env.BLU_ISSUER; // this environment's Blu, e.g. https://api.blu.example.com
151
+ const MODULE_ID = "pacing"; // your module's id in Blu
152
+
153
+ const { authenticate, authorize } = createModuleAuth({
154
+ apiUrl: process.env.PUBLIC_URL, // this API's own URL, as configured
155
+ strategies: [
156
+ bluPlatformAuth({
157
+ issuer: BLU_ISSUER,
158
+ jwksUrl: `${BLU_ISSUER}/.well-known/jwks.json`,
159
+ moduleId: MODULE_ID,
160
+ // YOUR mapping: a user's Blu role → this API's permissions.
161
+ roleMap: {
162
+ viewer: ["pacing:read"],
163
+ analyst: ["pacing:read", "pacing:write"],
164
+ owner: ["pacing:read", "pacing:write"],
165
+ },
166
+ }),
167
+ ],
168
+ });
169
+
170
+ const app = express();
171
+ app.use(corsAllowlist({ origins: [process.env.BLU_SHELL_ORIGIN] }));
172
+ app.use(express.json());
173
+
174
+ app.get("/api/campaigns", authenticate, authorize({ blu: ["pacing:read"] }), async (req, res) => {
175
+ const { clientId, displayName } = req.principal; // the client comes from the token, never the request
176
+ res.json(await campaignsFor(clientId));
177
+ });
178
+
179
+ app.listen(8080);
180
+ ```
181
+
182
+ That's a complete Blu-ready API:
183
+ - Requests without a valid Blu token for `pacing` get a `401`.
184
+ - Users whose role lacks `pacing:read` get a `403`.
185
+ - Every handler sees the user and their client.
186
+
187
+ ### Who can call your API
188
+
189
+ Each way of calling is a **strategy**. Enable the ones your API needs; one API can serve all of them at once.
190
+
191
+ | Entry point | Who | Strategy | Credential |
192
+ |---|---|---|---|
193
+ | `blu` | A user signed in to Blu, in the Blu shell, through the Concierge, or in your standalone app via "Sign in with Blu" | `bluPlatformAuth` | `Authorization: Bearer <Blu token>` |
194
+ | `service` | Another backend that Blu has granted access to your module | `bluServiceAuth` | `Authorization: Bearer <Blu service token>` plus `X-Client-Id` |
195
+ | `team` | A user of your own app, signed in with your own identity provider (Cognito, Auth0, Entra, homegrown…) | `teamIdpAuth` | `Authorization: Bearer <your token>` |
196
+ | `api-key` | An outside system you issue keys to (never a browser) | `apiKeyAuth` | `X-Api-Key: <key>` plus `X-Client-Id` |
197
+
198
+ #### `bluPlatformAuth({ issuer, jwksUrl, moduleId, roleMap })`
199
+
200
+ Accepts Blu user tokens issued **for your module only** (audience `blu-module:<moduleId>`). Blu issues them only while your module is enabled for that client.
201
+
202
+ | Option | Meaning |
203
+ |---|---|
204
+ | `issuer` | This environment's Blu token issuer. |
205
+ | `jwksUrl` | Blu's public keys, normally `<issuer>/.well-known/jwks.json`. Keys are cached and refetched when Blu rotates them; no redeploy needed. |
206
+ | `moduleId` | Your module's id in Blu. |
207
+ | `roleMap` | **Yours.** Each Blu role (`viewer`, `analyst`, `owner`) → your API's permissions. Blu says who the user is and their role; you decide what that role may do. A role you don't list gets no permissions. |
208
+
209
+ #### `bluServiceAuth({ issuer, jwksUrl, moduleId })`
210
+
211
+ Accepts service tokens that another backend got from Blu, with the scopes a Blu admin granted it on your module. The caller names the client with `X-Client-Id`. It must be one of the clients your module is enabled for; a single-client grant needs no header.
212
+
213
+ #### `teamIdpAuth({ issuer, jwksUrl, audience, toPrincipal? })`
214
+
215
+ Accepts tokens from **your own** identity provider, so your standalone app's users keep signing in the way they do today. By default it reads these claims:
216
+
217
+ | Claim | Meaning |
218
+ |---|---|
219
+ | `sub` | the user |
220
+ | `name`, `email` | display details |
221
+ | `cid` | the active client |
222
+ | `clients` | the clients they may use |
223
+ | `role` | their role |
224
+ | `perms` | their permissions |
225
+
226
+ If your tokens look different, pass `toPrincipal(payload)` to map them.
227
+
228
+ #### `apiKeyAuth({ keys })`
229
+
230
+ Server-to-server keys **you** issue. Store only a hash: `hashApiKey(key)` gives the SHA-256 the strategy compares against.
231
+
232
+ ```js
233
+ apiKeyAuth({
234
+ keys: [{ id: "acme-bi", name: "Acme BI export", hash: process.env.ACME_BI_KEY_HASH,
235
+ clients: ["acme"], permissions: ["pacing:read"], ratePerMinute: 60 }],
236
+ });
237
+ ```
238
+
239
+ - **Browsers are refused:** a request with an `Origin` header gets a `403`, because a key in a web page is a key anyone can copy.
240
+ - **Rate-limited per key:** 60 requests a minute by default.
241
+ - **Client scope:** a key covering several clients must name one with `X-Client-Id`.
242
+
243
+ #### Choosing entry points per deployment
244
+
245
+ `enabledModes("blu,team")` parses a comma-separated setting (such as an `AUTH_MODES` environment variable) into a set, and rejects unknown names. Use it to turn entry points on or off per environment without code changes.
246
+
247
+ ```js
248
+ const modes = enabledModes(process.env.AUTH_MODES); // default: all four
249
+ const strategies = [];
250
+ if (modes.has("blu")) strategies.push(bluPlatformAuth({ ... }));
251
+ if (modes.has("team")) strategies.push(teamIdpAuth({ ... }));
252
+ ```
253
+
254
+ ### The principal
255
+
256
+ After `authenticate`, `req.principal` describes the caller the same way whatever the entry point:
257
+
258
+ | Field | Meaning |
259
+ |---|---|
260
+ | `authMode` | `"blu"`, `"service"`, `"team"` or `"api-key"` |
261
+ | `kind` | `"user"` or `"service"` |
262
+ | `subject` | the user id (or service / key id) |
263
+ | `key` | a stable identity across requests: `<authMode>\|<issuer>\|<subject>`. Key your own records on it (see [Remembering Blu users](#remembering-blu-users)). |
264
+ | `displayName`, `email` | for display |
265
+ | `clientId` | **the active client**, from the verified credential |
266
+ | `allowedClients` | every client the credential covers |
267
+ | `roles` | Blu or team roles |
268
+ | `permissions` | resolved permissions (may include `"*"`) |
269
+ | `provider` | Blu users: how Blu signed them in (e.g. `"entra"`) |
270
+ | `via` | Blu users: the app that obtained the token (the Blu shell, the Concierge, or your own standalone app) |
271
+ | `actor` | Blu users: software acting for the user, e.g. `"blu-concierge"` |
272
+
273
+ **Always scope data by `req.principal.clientId`.** For user tokens it comes from the credential only, never from a query parameter or body.
274
+
275
+ ### Route policies
276
+
277
+ `authorize(policy)` names which entry points may call a route, and the permissions each needs (all listed are required):
278
+
279
+ ```js
280
+ app.get("/api/campaigns", authenticate,
281
+ authorize({ blu: ["pacing:read"], team: ["pacing:read"], service: ["pacing:read"] }), handler);
282
+
283
+ app.post("/api/campaigns/:id/flag", authenticate,
284
+ authorize({ blu: ["pacing:write"], team: ["pacing:write"] }), handler); // no services, no API keys
285
+
286
+ app.post("/api/admin/recompute", authenticate,
287
+ authorize({ team: ["pacing:admin"] }), handler); // your own users only
288
+ ```
289
+
290
+ - **Unlisted entry points are refused** (`403 entry_point_not_allowed`), even if the caller authenticated. That's how you keep an admin route away from Blu.
291
+ - **Clients can't be swapped.** A user token scoped to client A asking for client B (`?clientId=` or `X-Client-Id`) gets `403 client_mismatch`; it's never an override.
292
+ - **Missing permissions** get `403 forbidden`, naming what's missing.
293
+ - **`authorize({ blu: [], team: [] })`** allows those entry points with no particular permission.
294
+ - **`requirePermission("pacing:read")`** is shorthand for the same permission from every enabled entry point.
295
+ - **`allows(policy, principal, askedClientId?)`** is the same check as a boolean, for code outside Express.
296
+
297
+ ### Next.js and other Web-standard handlers
298
+
299
+ `createModuleAuth` also returns `verifyRequest(request)`. It takes a standard `Request` and returns the principal, or throws an `AuthError` (with `status` and `code`).
300
+
301
+ ```ts
302
+ // app/api/campaigns/route.ts
303
+ import { AuthError, createModuleAuth } from "@blu/sdk/server";
304
+
305
+ const { verifyRequest, allows } = createModuleAuth({ apiUrl: process.env.PUBLIC_URL, strategies: [ /* … */ ] });
306
+
307
+ export async function GET(request: Request) {
308
+ let caller;
309
+ try {
310
+ caller = await verifyRequest(request);
311
+ } catch (e) {
312
+ if (!(e instanceof AuthError)) throw e;
313
+ return Response.json({ error: e.code, detail: e.message }, { status: e.status });
314
+ }
315
+ if (!allows({ blu: ["pacing:read"], team: ["pacing:read"] }, caller)) {
316
+ return Response.json({ error: "forbidden" }, { status: 403 });
317
+ }
318
+ return Response.json(await campaignsFor(caller.clientId));
319
+ }
320
+ ```
321
+
322
+ For a whole Next.js app (pages, server actions, `auth()`), use `@blu/next-adapter`, which is built on this.
323
+
324
+ ### Remembering Blu users
325
+
326
+ A Blu user needs **no account in your system first**. `principal.key` is stable for that person, so you can attach your own data to it the first time you see them: who flagged a campaign, audit trails, preferences.
327
+
328
+ `createPrincipalDirectory()` shows the pattern: an Express middleware that records users on first sight.
329
+
330
+ ```js
331
+ const people = createPrincipalDirectory();
332
+ app.post("/api/campaigns/:id/flag", [authenticate, people.remember], authorize({ blu: ["pacing:write"] }), (req, res) => {
333
+ // req.principal.isNew is true on their first request; req.principal.firstSeen is when that was.
334
+ });
335
+ people.get(key); // one person
336
+ people.list(); // everyone seen
337
+ ```
338
+
339
+ It keeps people in memory. In production, store the same records in your own database, keyed on `principal.key`.
340
+
341
+ A Blu user and a user from your own identity provider are **different principals**, even if they're the same person. If you need to link them, do it in your own data, deliberately.
342
+
343
+ ### CORS
344
+
345
+ `corsAllowlist({ origins, credentials? })` is an explicit-allowlist CORS middleware:
346
+
347
+ - It echoes back only allowlisted origins and sets `Vary: Origin`.
348
+ - It answers preflights for the `Authorization`, `Content-Type` and `X-Client-Id` headers.
349
+ - Unlisted origins get no CORS headers, so the browser refuses to give the response to that page.
350
+
351
+ ```js
352
+ app.use(corsAllowlist({ origins: ["https://app.blu.example.com"] }));
353
+ // or from live config:
354
+ app.use(corsAllowlist({ origins: async () => loadAllowedOrigins() }));
355
+ ```
356
+
357
+ List your environment's Blu shell origins, plus your own standalone app if it calls this API from another origin. `bluShellOrigins(stage)` gives the shell's origin for `"dev"`, `"staging"` or `"prod"` (`https://dev.blu-ce.horizonmedia.com`; prod: `https://blu-ce.horizonmedia.com`). It's a list, so if the shell ever answers at a second name, your API picks it up with an SDK update and no code change:
358
+
359
+ ```js
360
+ import { bluShellOrigins } from "@blu/sdk/server";
361
+ app.use(corsAllowlist({ origins: [...bluShellOrigins(process.env.BLU_STAGE), "https://pacing.example.com"] }));
362
+ ```
363
+
364
+ `originLogger(name)` is a small request logger that prints each request's origin and credential type, which helps while setting this up.
365
+
366
+ ### Tools for the Blu Concierge (MCP)
367
+
368
+ The Blu Concierge answers users' questions by calling tools that modules expose over [MCP](https://modelcontextprotocol.io). Each tool is a small function that **your API runs, with your checks**:
369
+
370
+ - **Per-caller lists:** a caller only *sees* the tools their policy allows; tool lists differ per caller.
371
+ - **Client-scoped:** every call is for the caller's client, from the verified token.
372
+ - **Acting for the user:** the Concierge calls with a token for **your module**, on the user's behalf (`principal.actor` is `"blu-concierge"`), so it can never reach more than the user could.
373
+
374
+ ```js
375
+ import { z } from "zod";
376
+ import { createMcpEndpoint, link } from "@blu/sdk/server";
377
+ import manifest from "../blu.module.json" with { type: "json" };
378
+
379
+ const tools = [
380
+ {
381
+ name: "list_campaign_pacing",
382
+ title: "Campaigns with pacing",
383
+ description: "Pacing team: the current client's campaigns with spend vs budget and pacing status. Use for 'which campaigns are over/under pacing'.",
384
+ input: { status: z.enum(["over", "under", "on_track"]).optional() },
385
+ policy: { blu: ["pacing:read"] },
386
+ annotations: { readOnlyHint: true },
387
+ run: async (principal, { status }) => {
388
+ const rows = await campaignsFor(principal.clientId, { status });
389
+ return {
390
+ summary: rows.map((c) => `${c.name}: ${c.pacing}% paced`).join("\n"), // what the model reads
391
+ data: rows, // structured detail
392
+ links: rows.map((c) => link("campaign", { campaignId: c.id }, c.name)), // deep links into your module
393
+ };
394
+ },
395
+ },
396
+ ];
397
+
398
+ app.post("/mcp", ...createMcpEndpoint({ name: "pacing", version: "1.0.0", authenticate, tools, manifest }));
399
+ ```
400
+
401
+ For Web-standard handlers (for example a Next.js route), use `createMcpWebHandler`, which is the same thing:
402
+
403
+ ```ts
404
+ export const POST = createMcpWebHandler({ name: "pacing", version: "1.0.0", verify: verifyRequest, tools, manifest });
405
+ ```
406
+
407
+ **Writing a tool:**
408
+
409
+ | Field | Purpose |
410
+ |---|---|
411
+ | `description` | Written **for the model**: when to use the tool and what it returns. |
412
+ | `input` | Arguments, as a [zod](https://zod.dev) shape: Zod 4.6.6 or later (3.25+ still works, but earlier versions can exhaust memory validating oversized input, GHSA-mrhx-c7xc-m3w5; `blu check` warns). |
413
+ | `policy` | The same format as `authorize()`. |
414
+ | `annotations.readOnlyHint` | Marks a tool that doesn't change anything. The Blu Concierge only ever uses these: for a caller acting for a user (`principal.actor`, e.g. the Concierge) the endpoint doesn't list or run any other tool. |
415
+ | `run(principal, args)` | Returns `summary` (text for the model), `data` (structured), and optional `links`. |
416
+
417
+ If `run` throws, the error message goes back to the Concierge as a tool error; nothing crashes.
418
+
419
+ **Links name routes, never URLs.** `link(routeName, params, label)` refers to a route in your `blu.module.json` `routes`. Blu builds the URL, so links can only point at routes you declared. Links to undeclared routes, or with missing parameters, are dropped.
420
+
421
+ **Your manifest is enforced.** Pass `manifest` (your `blu.module.json`), and the tools in code must match its `mcp.tools` exactly: names, read-only flags and required permissions. If they don't, the endpoint refuses to start, so the tools a Blu admin reviewed are the tools that run.
422
+
423
+ ```json
424
+ "mcp": {
425
+ "tools": [
426
+ { "name": "list_campaign_pacing", "title": "Campaigns with pacing", "readOnly": true, "requires": { "blu": ["pacing:read"] } }
427
+ ]
428
+ }
429
+ ```
430
+
431
+ ### Errors
432
+
433
+ Refusals are JSON: `{ "error": "<code>", "detail": "<readable explanation>" }`. With `verifyRequest`, the same codes arrive as an `AuthError` with `status` and `code`.
434
+
435
+ | Status | `error` | When |
436
+ |---|---|---|
437
+ | 400 | `ambiguous_credentials` | both a bearer token and an API key were sent |
438
+ | 400 | `client_required` | a service or multi-client API key didn't send `X-Client-Id` |
439
+ | 401 | `missing_credentials` | no `Authorization: Bearer` or `X-Api-Key`. **Cookies are never accepted.** |
440
+ | 401 | `invalid_token` | not a JWT, a bad signature, or expired |
441
+ | 401 | `untrusted_issuer` | the token's issuer isn't one you configured |
442
+ | 401 | `mode_disabled` | that kind of credential isn't enabled on this API |
443
+ | 401 | `invalid_api_key` | unknown or revoked key |
444
+ | 401 | `dev_identity_refused` | a local-only test sign-in, sent to a non-local API |
445
+ | 403 | `no_active_client` | the token has no active client in its scope |
446
+ | 403 | `client_not_granted` | the service or key isn't granted that client |
447
+ | 403 | `api_key_from_browser` | an API key sent from a web page |
448
+ | 403 | `entry_point_not_allowed` | this route doesn't accept this kind of caller |
449
+ | 403 | `client_mismatch` | the request asked for a client other than the token's |
450
+ | 403 | `forbidden` | permissions missing (named in `detail`) |
451
+ | 429 | `rate_limited` | an API key exceeded its rate |
452
+
453
+ ### Configuration per environment
454
+
455
+ Each environment (dev, staging, prod) has its own Blu. Configure your API per environment:
456
+
457
+ | Setting | Example |
458
+ |---|---|
459
+ | Blu issuer | `bluIssuer(stage)`: `https://api.dev.blu-ce.horizonmedia.com` (prod: `https://api.blu-ce.horizonmedia.com`) |
460
+ | Blu public keys | `<issuer>/.well-known/jwks.json` |
461
+ | Your module id | the `id` in your `blu.module.json` |
462
+ | Blu shell origins for CORS | `bluShellOrigins(stage)`: `https://dev.blu-ce.horizonmedia.com` (prod: `https://blu-ce.horizonmedia.com`) |
463
+ | This API's public URL (`apiUrl`) | `https://pacing-api.dev.example.com` |
464
+ | Enabled entry points | `AUTH_MODES=blu,service,team` |
465
+
466
+ A token from one environment's Blu is refused by another environment's API, because the issuer differs.
467
+
468
+ ### Security behaviour you get for free
469
+
470
+ - **Fails closed.** Each of these is refused rather than allowed:
471
+ - an unknown issuer;
472
+ - a disabled entry point;
473
+ - a module that isn't enabled for the client;
474
+ - an unresolved client;
475
+ - an entry point the route doesn't list.
476
+ - **Your module only.** Blu tokens are checked for your module's audience. A token Blu issued for another module, or for Blu's own use, is refused.
477
+ - **Short-lived tokens.** Blu tokens expire in about a minute, so a user whose access is removed in Blu is refused within about a minute, with no session store on your side.
478
+ - **No cookies.** Credentials come only from headers, so other websites can't make requests on a user's behalf (cross-site request forgery).
479
+ - **Key rotation without redeploys.** Blu's public keys are cached and refetched automatically when Blu rotates them.
480
+ - **Local test sign-ins stay local.** Blu's password-free sign-ins for developers' machines produce tokens marked as such. `createModuleAuth` accepts them only when `apiUrl` points at the local machine, so a deployed API always refuses them. Leave `apiUrl` out and they're always refused.
481
+ - **API keys:** stored as hashes, compared in constant time, rate-limited, and never accepted from browsers.
482
+
483
+ ### Server reference
484
+
485
+ | Export | Use |
486
+ |---|---|
487
+ | `createModuleAuth({ strategies, apiUrl? })` | Combines strategies. Returns `{ authenticate, authorize, requirePermission, verifyRequest, allows, modes }`. |
488
+ | `bluPlatformAuth({ issuer, jwksUrl, moduleId, roleMap })` | Blu users. |
489
+ | `bluServiceAuth({ issuer, jwksUrl, moduleId })` | Backends granted access by Blu. |
490
+ | `teamIdpAuth({ issuer, jwksUrl, audience, toPrincipal? })` | Your own identity provider's users. |
491
+ | `apiKeyAuth({ keys })` | Keys you issue to outside systems. |
492
+ | `hashApiKey(key)` | The SHA-256 to store for a key. |
493
+ | `enabledModes(value)` | Parse `"blu,team,…"` into a set of entry points. |
494
+ | `createPrincipalDirectory()` | Remember the people who call you (`remember`, `get`, `list`). |
495
+ | `corsAllowlist({ origins, credentials? })` | Explicit-allowlist CORS for Express. |
496
+ | `originLogger(name)` | Log each request's origin and credential type. |
497
+ | `createMcpEndpoint({ name, version, authenticate, tools, manifest? })` | MCP tools for the Concierge, on Express. |
498
+ | `createMcpWebHandler({ name, version, verify, tools, manifest? })` | The same, for Web-standard handlers. |
499
+ | `link(route, params, label)` | A deep link from a tool result into your module. |
500
+ | `policyAllows(policy, principal)` | True when a policy admits the principal (as used for tool lists). |
501
+ | `AuthError` | What `verifyRequest` throws: `status`, `code`, `message`. |
502
+
503
+ ---
504
+
505
+ ## Checking your integration: `blu check`
506
+
507
+ The SDK comes with a `blu` command. Run it in your module's folder (where `blu.module.json` is):
508
+
509
+ ```sh
510
+ npx blu check # or: pnpm exec blu check
511
+ npx blu check --json # machine-readable
512
+ ```
513
+
514
+ It checks your manifest with the same rules Blu Admin applies, then your code against it:
515
+ - only public `@blu/sdk` entry points are imported (nothing internal or renamed);
516
+ - `@blu/sdk` is installed under its alias, and no `.npmrc` still sends it to GitHub Packages;
517
+ - inline modules share `@blu/sdk` as a singleton, with the manifest's federation name and exposed key;
518
+ - isolated modules connect with `mountIsolated` / `connectToBluShell`;
519
+ - your API's `moduleId` is the manifest's `id`;
520
+ - every declared Concierge tool is defined, and the MCP endpoint is given the manifest;
521
+ - tool links only name declared routes.
522
+
523
+ - your bundle addresses name the release's folder (`{version}`), so every release keeps its own files and rollback works (a warning).
524
+
525
+ It exits with 1 when something fails, so add it to CI. It's static: it reads your code but doesn't run it, so keep your own build and tests too.
526
+
527
+ ## Registering a release: `blu register`
528
+
529
+ Once per release, after uploading its files to their versioned folder, your pipeline registers it with Blu:
530
+
531
+ ```sh
532
+ BLU_CI_TOKEN=${{ secrets.BLU_CI_TOKEN }} npx blu register
533
+ ```
534
+
535
+ It sends `blu.module.json` to the Blu registry, `https://registry.blu-ce.horizonmedia.com` (`REGISTRY_URL`; `BLU_REGISTRY_URL` or `--registry` overrides it). The token is your module's CI token, from the Blu team. Releases are immutable: the same release again is a no-op, and different content under an existing version is refused, so bump `version` for every release. Registering runs it nowhere; Blu admins approve it environment by environment.
536
+
537
+ What happens next:
538
+ - **Blu copies your release** (inline modules) into its own module store and serves it from there, so keep your folder up until the copy is made (minutes); after that, Blu keeps every version it might need (30 days after it last ran anywhere, and always the current and previous one). Build with relative asset paths (`base: "./"` in Vite) so the copied files work from Blu's address. Isolated (iframe) modules keep loading from your origin: keep those folders up for 30 days after a version last ran.
539
+ - **Blu reviews it:** known-vulnerable shared packages, code rules (no code built at runtime, no reaching into the Blu page, no undeclared hosts), and an AI review. Read the report with your CI token: `GET https://registry.blu-ce.horizonmedia.com/v1/modules/<id>/versions/<version>/report`.
540
+ - **Keep your API backward-compatible** for about 30 days: an environment can be rolled back to an earlier front end, which calls your current API.
541
+
542
+ **Using Claude Code?** The Blu plugin (`plugins/blu` in the Blu repository) has an `/blu:integrate` skill that walks the whole integration and finishes with `blu check`, and `/blu:check`.
543
+
544
+ ---
545
+
546
+ ## Versions
547
+
548
+ - **Inside Blu, your front end runs Blu's copy of `@blu/sdk`.** The shell shares one instance with every module, so they all read the same context. Your installed version only affects types, your build, and running on your own.
549
+ - **Use a range:** `^1.5.0` means "1.5.0 or newer, within 1.x". If your module needs a newer feature than the shell provides, Blu's loader warns at runtime. Upgrade within a major version whenever you like; Blu keeps 1.x compatible.
550
+
551
+ ## Access
552
+
553
+ The packages are public on npm, in the **horizonmedia** npm organization, so anyone can install them. Only this repository's workflow (`.github/workflows/publish-sdks.yml`) publishes them. It uses npm's trusted publishing: each package names that workflow as its trusted publisher (npmjs.com → package → **Settings** → **Trusted Publisher**: GitHub Actions, organization `HorizonMedia`, repository `blu-composable-experience`, workflow `publish-sdks.yml`), and publishing access is set to require 2FA and disallow tokens. Until that's set up, the workflow uses the `NPM_TOKEN` Actions secret.