@vantageos/mcp-boilerplate 0.2.3 → 0.3.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 (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +161 -0
  3. package/README.fr.md +323 -15
  4. package/README.md +306 -15
  5. package/dist/buildIdentity.d.ts +19 -0
  6. package/dist/buildIdentity.js +62 -0
  7. package/dist/declaredVariables.d.ts +205 -0
  8. package/dist/declaredVariables.js +451 -0
  9. package/dist/exampleItems.d.ts +15 -0
  10. package/dist/exampleItems.js +28 -1
  11. package/dist/httpApp.d.ts +17 -0
  12. package/dist/httpApp.js +19 -2
  13. package/dist/i18n.d.ts +10 -0
  14. package/dist/i18n.js +12 -0
  15. package/dist/index.d.ts +12 -0
  16. package/dist/index.js +6 -0
  17. package/dist/lake/convex.d.ts +101 -0
  18. package/dist/lake/convex.js +224 -0
  19. package/dist/lake/reader.d.ts +88 -0
  20. package/dist/lake/reader.js +120 -0
  21. package/dist/locales/en.json +42 -0
  22. package/dist/locales/fr.json +42 -0
  23. package/dist/mcpServerFactory.d.ts +31 -14
  24. package/dist/mcpServerFactory.js +116 -48
  25. package/dist/oauth/authorizationServer.js +64 -18
  26. package/dist/oauth/clerk.d.ts +2 -0
  27. package/dist/oauth/clerk.js +15 -5
  28. package/dist/oauth/clientRegistry.d.ts +19 -4
  29. package/dist/oauth/clientRegistry.js +20 -10
  30. package/dist/oauth/picker.d.ts +7 -2
  31. package/dist/oauth/picker.js +90 -13
  32. package/dist/oauth/registerLimiter.d.ts +34 -0
  33. package/dist/oauth/registerLimiter.js +95 -0
  34. package/dist/oauth/settings.d.ts +4 -0
  35. package/dist/oauth/settings.js +4 -0
  36. package/dist/pagination.d.ts +66 -0
  37. package/dist/pagination.js +100 -0
  38. package/dist/serverManifest.d.ts +32 -0
  39. package/dist/serverManifest.js +68 -0
  40. package/dist/stdio.js +5 -1
  41. package/dist/tenancy.d.ts +1 -1
  42. package/dist/toolRecord.d.ts +94 -0
  43. package/dist/toolRecord.js +52 -0
  44. package/dist/ui/uiResource.d.ts +11 -4
  45. package/dist/ui/uiResource.js +8 -4
  46. package/dist/upstream/clientCredentials.d.ts +106 -0
  47. package/dist/upstream/clientCredentials.js +283 -0
  48. package/dist/upstream/paginatedClient.d.ts +142 -0
  49. package/dist/upstream/paginatedClient.js +422 -0
  50. package/dist/verifiedPerson.d.ts +85 -0
  51. package/dist/verifiedPerson.js +112 -0
  52. package/docs/oauth-stores.md +2 -1
  53. package/examples/oauth.env.example +7 -0
  54. package/mcp-server.json +13 -0
  55. package/package.json +19 -4
  56. package/plugin.json +1 -1
  57. package/skills/connect-mcp-server/SKILL.md +5 -5
@@ -20,20 +20,24 @@
20
20
  * FAIL-CLOSED: any missing configuration value answers 503 `temporarily_unavailable`.
21
21
  * No refresh tokens are issued; access tokens live one hour.
22
22
  */
23
- import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resumeAuthorize, startAuthorize, resolveClerkSessionFromRequest, } from "@vantageos/cloud-identity";
23
+ import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resolveClerkSessionFromRequest, resumeAuthorize, startAuthorize, } from "@vantageos/cloud-identity";
24
24
  import { getCookie } from "hono/cookie";
25
25
  import { cors } from "hono/cors";
26
26
  import { flagKnob, integerKnob, originListKnob } from "../envKnobs.js";
27
- import { detectLocaleFromAcceptLanguage, t } from "../i18n.js";
27
+ import { detectLocale, detectLocaleFromAcceptLanguage, t } from "../i18n.js";
28
28
  import { createClerkBackendCollaborators } from "./clerk.js";
29
29
  import { ASSERTION_SIGNING_ALGS, createClientAssertionVerifier } from "./clientAssertion.js";
30
- import { createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
30
+ import { RegistryFullError, createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
31
31
  import { createInMemoryCodeStore } from "./codeStore.js";
32
32
  import { pickerContentSecurityPolicy, renderOrgPicker } from "./picker.js";
33
+ import { createRegisterLimiter } from "./registerLimiter.js";
33
34
  import { DEFAULT_SCOPE, OAUTH_ENV, SUPPORTED_SCOPES, isDevClerkInstanceOnRemoteServer, readAuthorizeSettings, } from "./settings.js";
34
35
  import { ACCESS_TOKEN_TTL_SECONDS, createTokenIssuer } from "./tokenIssuer.js";
35
36
  const REGISTER_LIMIT_PER_MINUTE = 20;
37
+ /** Default of OAUTH_ENV.registerLimitPer48: five /64 budgets for a whole /48. */
38
+ const REGISTER_LIMIT_PER_48_PER_MINUTE = 100;
36
39
  const MAX_REDIRECT_URIS = 10;
40
+ const REGISTRY_FULL_RETRY_AFTER_SECONDS = 300;
37
41
  function noStore(c) {
38
42
  c.header("Cache-Control", "no-store");
39
43
  c.header("Pragma", "no-cache");
@@ -84,11 +88,16 @@ export function createAuthorizationServer(deps = {}) {
84
88
  const env = deps.env ?? process.env;
85
89
  // Operator knobs are validated here, at construction: a bad value stops the boot.
86
90
  const trustedProxyCount = integerKnob(env, OAUTH_ENV.trustedProxyCount, 0, 10) ?? 0;
91
+ const registerLimitPer48 = integerKnob(env, OAUTH_ENV.registerLimitPer48, 1) ?? REGISTER_LIMIT_PER_48_PER_MINUTE;
87
92
  const registerOrigins = originListKnob(env, OAUTH_ENV.registerCorsOrigins);
88
93
  const allowInMemory = flagKnob(env, OAUTH_ENV.allowInMemoryStores) === true;
89
94
  const now = deps.now ?? (() => Date.now());
90
95
  // Fail at boot, loudly, rather than lose every code and client on the first restart.
91
96
  if (!deps.codeStore || !deps.clientRegistry) {
97
+ if (env.NODE_ENV !== "production" && !allowInMemory) {
98
+ // Development, test or unset: start, but never silently. One line, both languages.
99
+ console.warn(`[oauth] ${t("en", "oauth.inMemoryWarning").replace("{knob}", OAUTH_ENV.allowInMemoryStores)} / ${t("fr", "oauth.inMemoryWarning").replace("{knob}", OAUTH_ENV.allowInMemoryStores)}`);
100
+ }
92
101
  if (env.NODE_ENV === "production") {
93
102
  if (!allowInMemory) {
94
103
  throw new Error(`NODE_ENV=production refuses the in-memory OAuth stores (codes and registered clients are lost on restart and not shared between instances). Inject durable adapters (docs/oauth-stores.md), or set ${OAUTH_ENV.allowInMemoryStores}=1 to accept a single-instance deployment that forgets them on restart.`);
@@ -177,6 +186,18 @@ export function createAuthorizationServer(deps = {}) {
177
186
  error_description: t("en", "oauth.unavailable"),
178
187
  }, 503);
179
188
  };
189
+ /**
190
+ * The redirect URI(s) the picker's form may end up at. cloud-identity hands the page only the
191
+ * ORIGIN of the validated redirect_uri: for http(s) that is the destination itself; for a
192
+ * custom scheme the origin is the opaque "null", so the scheme is read from the client's
193
+ * registered URIs of that scheme (the validated one is among them).
194
+ */
195
+ const redirectTargetsOf = async (model) => {
196
+ if (/^https?:\/\//.test(model.redirectOrigin))
197
+ return [model.redirectOrigin];
198
+ const client = await lookupClient(model.client.clientId);
199
+ return (client?.redirectUris ?? []).filter((u) => !/^https?:/i.test(u));
200
+ };
180
201
  const respond = async (c, outcome, settings) => {
181
202
  noStore(c);
182
203
  c.header("Referrer-Policy", "no-referrer");
@@ -190,11 +211,21 @@ export function createAuthorizationServer(deps = {}) {
190
211
  return c.redirect(back.toString(), 302);
191
212
  }
192
213
  case "org-picker": {
193
- c.header("Content-Security-Policy", await pickerContentSecurityPolicy());
214
+ let csp;
215
+ try {
216
+ csp = await pickerContentSecurityPolicy(await redirectTargetsOf(outcome.model));
217
+ }
218
+ catch (err) {
219
+ // Fail closed: without a parseable destination the page is not rendered at all.
220
+ console.error("[oauth] picker refused:", err instanceof Error ? err.message : err);
221
+ return c.json({ error: "server_error", error_description: "picker unavailable" }, 500);
222
+ }
223
+ c.header("Content-Security-Policy", csp);
194
224
  const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
195
225
  return c.html(renderOrgPicker(outcome.model, locale, {
196
226
  // Only a URL-shaped (CIMD) client_id is bound to a domain its owner controls.
197
227
  unverified: !isUrlShapedClientId(outcome.model.client.clientId),
228
+ brandName: env[OAUTH_ENV.brandName]?.trim() || undefined,
198
229
  }));
199
230
  }
200
231
  case "refused": {
@@ -203,19 +234,11 @@ export function createAuthorizationServer(deps = {}) {
203
234
  }
204
235
  }
205
236
  };
206
- const registerHits = new Map();
207
- const registerAllowed = (key) => {
208
- const t0 = now();
209
- const hit = registerHits.get(key);
210
- if (!hit || t0 - hit.windowStart >= 60_000) {
211
- registerHits.set(key, { windowStart: t0, count: 1 });
212
- if (registerHits.size > 5000)
213
- registerHits.clear();
214
- return true;
215
- }
216
- hit.count += 1;
217
- return hit.count <= REGISTER_LIMIT_PER_MINUTE;
218
- };
237
+ const registerLimiter = createRegisterLimiter({
238
+ limitPerMinute: REGISTER_LIMIT_PER_MINUTE,
239
+ prefix48LimitPerMinute: registerLimitPer48,
240
+ now,
241
+ });
219
242
  return {
220
243
  async resourceServer() {
221
244
  const result = await settingsNow();
@@ -281,7 +304,7 @@ export function createAuthorizationServer(deps = {}) {
281
304
  });
282
305
  app.post("/register", async (c) => {
283
306
  const key = clientKeyOf(c, trustedProxyCount);
284
- if (!registerAllowed(key)) {
307
+ if (!registerLimiter.allow(key)) {
285
308
  c.header("Retry-After", "60");
286
309
  return c.json({ error: "too_many_requests" }, 429);
287
310
  }
@@ -325,6 +348,19 @@ export function createAuthorizationServer(deps = {}) {
325
348
  });
326
349
  }
327
350
  catch (err) {
351
+ if (err instanceof RegistryFullError) {
352
+ // RFC 7591 style JSON error; the caller retries, no live client was displaced.
353
+ const locale = c.req.header("x-mcp-locale")
354
+ ? detectLocale(c.req.header("x-mcp-locale"))
355
+ : detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
356
+ console.error("[oauth] /register refused: client registry is full");
357
+ c.header("Retry-After", String(REGISTRY_FULL_RETRY_AFTER_SECONDS));
358
+ noStore(c);
359
+ return c.json({
360
+ error: "temporarily_unavailable",
361
+ error_description: t(locale, "oauth.registryFull"),
362
+ }, 503);
363
+ }
328
364
  console.error("[oauth] /register failed:", err instanceof Error ? err.message : err);
329
365
  return c.json({ error: "server_error", error_description: "failed to persist client" }, 500);
330
366
  }
@@ -408,6 +444,16 @@ export function createAuthorizationServer(deps = {}) {
408
444
  // session, then answers the redirect-to-client with error=access_denied and the
409
445
  // client's state. `respond` appends `iss` to it like to every authorization response.
410
446
  if (field("approved") === "false") {
447
+ // The denial is a state-changing POST like the approval. An absent token is
448
+ // refused here with a localized 400; a present one is verified by the package
449
+ // (cloud-identity >= 0.18) with the approve-path function, and a forged one is
450
+ // refused with no redirect.
451
+ if (!field("consentToken")) {
452
+ return c.json({
453
+ error: "invalid_request",
454
+ error_description: t(detectLocaleFromAcceptLanguage(c.req.header("accept-language")), "oauth.consentTokenRequired"),
455
+ }, 400);
456
+ }
411
457
  const denied = await resumeAuthorize({
412
458
  state: field("state") ?? "",
413
459
  sessionToken: sessionTokenFrom(c),
@@ -15,5 +15,7 @@ export interface ClerkCollaborators {
15
15
  export interface ClerkBackendOptions {
16
16
  /** Per-call time budget (JWKS, memberships, user). Default 5 s. */
17
17
  timeoutMs?: number;
18
+ /** Total budget of ONE lookup across all its pages and calls. Default 15 s. */
19
+ totalTimeoutMs?: number;
18
20
  }
19
21
  export declare function createClerkBackendCollaborators(secretKey: string, fetchImpl?: typeof fetch, options?: ClerkBackendOptions): ClerkCollaborators;
@@ -12,13 +12,22 @@ const MEMBERSHIP_PAGE_LIMIT = 100;
12
12
  /** 50 pages x 100 = 5 000 memberships: past it the lookup FAILS rather than truncate. */
13
13
  const MEMBERSHIP_MAX_PAGES = 50;
14
14
  const CLERK_TIMEOUT_MS = 5000;
15
+ const CLERK_TOTAL_TIMEOUT_MS = 15_000;
15
16
  export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, options = {}) {
16
17
  const timeoutMs = options.timeoutMs ?? CLERK_TIMEOUT_MS;
17
18
  let cachedJwks = null;
18
- const api = async (path) => {
19
+ const totalMs = options.totalTimeoutMs ?? CLERK_TOTAL_TIMEOUT_MS;
20
+ /** Per-call timeout AND what is left of the lookup's total deadline, whichever is sooner. */
21
+ const signalFor = (deadline) => {
22
+ const left = deadline - Date.now();
23
+ if (left <= 0)
24
+ throw new Error(`Clerk lookup exceeded its total deadline of ${totalMs} ms`);
25
+ return AbortSignal.any([AbortSignal.timeout(timeoutMs), AbortSignal.timeout(left)]);
26
+ };
27
+ const api = async (path, deadline) => {
19
28
  const res = await fetchImpl(`${CLERK_API}${path}`, {
20
29
  headers: { Authorization: `Bearer ${secretKey}`, Accept: "application/json" },
21
- signal: AbortSignal.timeout(timeoutMs),
30
+ signal: signalFor(deadline),
22
31
  });
23
32
  if (!res.ok)
24
33
  throw new Error(`Clerk Backend API answered ${res.status}`);
@@ -31,7 +40,7 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, op
31
40
  return cachedJwks.keys;
32
41
  }
33
42
  const res = await fetchImpl(`${issuer}/.well-known/jwks.json`, {
34
- signal: AbortSignal.timeout(timeoutMs),
43
+ signal: signalFor(now + totalMs),
35
44
  });
36
45
  if (!res.ok)
37
46
  throw new Error(`Clerk JWKS answered ${res.status}`);
@@ -41,9 +50,10 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, op
41
50
  },
42
51
  async listMemberships(userId) {
43
52
  const all = [];
53
+ const deadline = Date.now() + totalMs;
44
54
  for (let page = 0; page < MEMBERSHIP_MAX_PAGES; page++) {
45
55
  const offset = page * MEMBERSHIP_PAGE_LIMIT;
46
- const body = await api(`/users/${encodeURIComponent(userId)}/organization_memberships?limit=${MEMBERSHIP_PAGE_LIMIT}&offset=${offset}`);
56
+ const body = await api(`/users/${encodeURIComponent(userId)}/organization_memberships?limit=${MEMBERSHIP_PAGE_LIMIT}&offset=${offset}`, deadline);
47
57
  all.push(...body.data);
48
58
  const last = body.data.length < MEMBERSHIP_PAGE_LIMIT ||
49
59
  (typeof body.total_count === "number" && all.length >= body.total_count);
@@ -62,7 +72,7 @@ export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch, op
62
72
  throw new Error(`Clerk memberships exceed ${MEMBERSHIP_MAX_PAGES * MEMBERSHIP_PAGE_LIMIT}`);
63
73
  },
64
74
  async getUser(userId) {
65
- const user = await api(`/users/${encodeURIComponent(userId)}`);
75
+ const user = await api(`/users/${encodeURIComponent(userId)}`, Date.now() + totalMs);
66
76
  return {
67
77
  id: user.id,
68
78
  primaryEmailAddressId: user.primary_email_address_id ?? null,
@@ -25,20 +25,35 @@ export interface ClientRegistry {
25
25
  register(client: RegisteredClient): Promise<void>;
26
26
  }
27
27
  export interface InMemoryClientRegistryOptions {
28
- /** Hard cap on stored clients; at the cap the OLDEST client is evicted. Default 10 000. */
28
+ /** Hard cap on stored clients; at the cap a new registration is REFUSED. Default 10 000. */
29
29
  maxClients?: number;
30
30
  /** Lifetime of a registration, counted from `issuedAt`. Default 30 days. */
31
31
  ttlSeconds?: number;
32
32
  now?: () => number;
33
33
  }
34
34
  /**
35
- * Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit and
36
- * never stops accepting registrations either: expired clients are swept on every registration,
37
- * and at the cap the oldest client is evicted (an evicted client re-registers).
35
+ * Thrown by `register` when the registry is full of live clients. The route answers 503 with
36
+ * `Retry-After`; a durable adapter may throw the same error for its own quota.
37
+ */
38
+ export declare class RegistryFullError extends Error {
39
+ readonly code = "registry_full";
40
+ constructor();
41
+ }
42
+ /**
43
+ * Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit.
44
+ * Expired clients are pruned on every registration BEFORE the cap is judged; at the cap a new
45
+ * registration is refused with `RegistryFullError`. A live client is never evicted: an
46
+ * unauthenticated flood must not be able to push a real client out (eviction, introduced for
47
+ * issue #9, allowed exactly that).
38
48
  */
39
49
  export declare function createInMemoryClientRegistry(options?: InMemoryClientRegistryOptions): ClientRegistry;
40
50
  /** True for an https URL, or an http URL on a loopback host (native-app redirects). */
41
51
  export declare function isAllowedRedirectUri(raw: unknown): raw is string;
52
+ /**
53
+ * The eight 16-bit groups of a SYNTACTICALLY VALID IPv6 literal (`isIP` has already accepted
54
+ * it). Only used to read bits that no BlockList prefix can express.
55
+ */
56
+ export declare function ipv6Groups(ip: string): number[];
42
57
  /** True only for a globally routable address. Fail-closed: anything unrecognised is refused. */
43
58
  export declare function isPublicAddress(ip: string): boolean;
44
59
  export declare function defaultResolveHost(host: string): Promise<string[]>;
@@ -13,9 +13,22 @@
13
13
  import { lookup } from "node:dns/promises";
14
14
  import { BlockList, isIP } from "node:net";
15
15
  /**
16
- * Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit and
17
- * never stops accepting registrations either: expired clients are swept on every registration,
18
- * and at the cap the oldest client is evicted (an evicted client re-registers).
16
+ * Thrown by `register` when the registry is full of live clients. The route answers 503 with
17
+ * `Retry-After`; a durable adapter may throw the same error for its own quota.
18
+ */
19
+ export class RegistryFullError extends Error {
20
+ code = "registry_full";
21
+ constructor() {
22
+ super("client registry is full");
23
+ this.name = "RegistryFullError";
24
+ }
25
+ }
26
+ /**
27
+ * Bounded in-memory registry. DCR is unauthenticated, so it can never grow without limit.
28
+ * Expired clients are pruned on every registration BEFORE the cap is judged; at the cap a new
29
+ * registration is refused with `RegistryFullError`. A live client is never evicted: an
30
+ * unauthenticated flood must not be able to push a real client out (eviction, introduced for
31
+ * issue #9, allowed exactly that).
19
32
  */
20
33
  export function createInMemoryClientRegistry(options = {}) {
21
34
  const maxClients = options.maxClients ?? 10_000;
@@ -38,12 +51,9 @@ export function createInMemoryClientRegistry(options = {}) {
38
51
  for (const [id, row] of clients)
39
52
  if (expired(row))
40
53
  clients.delete(id);
41
- clients.delete(client.clientId); // re-registering moves it to the newest position
42
- while (clients.size >= maxClients) {
43
- const oldest = clients.keys().next();
44
- if (oldest.done)
45
- break;
46
- clients.delete(oldest.value);
54
+ // Re-registering an id already held replaces it: that is not a new slot.
55
+ if (!clients.has(client.clientId) && clients.size >= maxClients) {
56
+ throw new RegistryFullError();
47
57
  }
48
58
  clients.set(client.clientId, client);
49
59
  },
@@ -132,7 +142,7 @@ for (const [addr, len] of BLOCKED_V6)
132
142
  * The eight 16-bit groups of a SYNTACTICALLY VALID IPv6 literal (`isIP` has already accepted
133
143
  * it). Only used to read bits that no BlockList prefix can express.
134
144
  */
135
- function ipv6Groups(ip) {
145
+ export function ipv6Groups(ip) {
136
146
  let text = ip.toLowerCase();
137
147
  const dotted = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text);
138
148
  if (dotted) {
@@ -6,11 +6,16 @@
6
6
  */
7
7
  import type { OrgPickerModel } from "@vantageos/cloud-identity";
8
8
  import { type McpLocale } from "../i18n.js";
9
- /** CSP for the picker page: no scripts, no external assets, one hashed inline style. */
10
- export declare function pickerContentSecurityPolicy(): Promise<string>;
9
+ /**
10
+ * CSP for the picker page: no scripts, no external assets, one hashed inline style, and forms
11
+ * that post to this server and are redirected to the client's `redirect_uri` only.
12
+ */
13
+ export declare function pickerContentSecurityPolicy(redirectUris: readonly string[]): Promise<string>;
11
14
  /** What the consent page must tell the person beyond the model: whether the client is trusted. */
12
15
  export interface ConsentContext {
13
16
  /** True for a Dynamic Client Registration client: it vouched for itself, nobody else did. */
14
17
  unverified: boolean;
18
+ /** The deployment's display name (AUTHORIZE_BRAND_NAME), shown above the title; none by default. */
19
+ brandName?: string;
15
20
  }
16
21
  export declare function renderOrgPicker(model: OrgPickerModel, locale: McpLocale, consent: ConsentContext): string;
@@ -13,46 +13,123 @@ function escapeHtml(value) {
13
13
  .replace(/"/g, "&quot;")
14
14
  .replace(/'/g, "&#39;");
15
15
  }
16
- const PICKER_STYLE = "body{font-family:system-ui,sans-serif;max-width:32rem;margin:3rem auto;padding:0 1rem;line-height:1.5}" +
17
- "fieldset{border:1px solid #888;padding:1rem}label{display:block;margin:.5rem 0}" +
18
- "button{font:inherit;padding:.5rem 1rem;margin-right:.5rem}";
19
- /** CSP for the picker page: no scripts, no external assets, one hashed inline style. */
20
- export async function pickerContentSecurityPolicy() {
16
+ /**
17
+ * The VantagePeers dashboard design tokens (vantage-peers-dashboard styles/presets/base.css at
18
+ * 9ca86e8a): dark first, light under `prefers-color-scheme`. Copied as values because the page
19
+ * loads no external asset (CSP below); a change to the dashboard preset is carried here by hand.
20
+ * Geist is the dashboard font; with no external fonts allowed, the system stack stands in.
21
+ */
22
+ const PICKER_STYLE = [
23
+ ":root{color-scheme:dark light;--background:oklch(0.08 0.005 265);--foreground:oklch(0.95 0.005 265);" +
24
+ "--card:oklch(0.11 0.005 265);--card-hover:oklch(0.14 0.005 265);--primary:oklch(0.95 0.005 265);" +
25
+ "--primary-foreground:oklch(0.08 0.005 265);--secondary:oklch(0.16 0.005 265);" +
26
+ "--muted-foreground:oklch(0.55 0.005 265);--border:oklch(0.22 0.005 265);" +
27
+ "--border-hover:oklch(0.30 0.005 265);--ring:oklch(0.55 0.005 265);--warning:oklch(0.75 0.18 85);" +
28
+ "--radius:0.75rem}",
29
+ "@media (prefers-color-scheme:light){:root{--background:oklch(0.98 0.003 265);" +
30
+ "--foreground:oklch(0.10 0.005 265);--card:oklch(1 0 0);--card-hover:oklch(0.96 0.003 265);" +
31
+ "--primary:oklch(0.10 0.005 265);--primary-foreground:oklch(0.98 0.003 265);" +
32
+ "--secondary:oklch(0.94 0.003 265);--muted-foreground:oklch(0.45 0.005 265);" +
33
+ "--border:oklch(0.88 0.003 265);--border-hover:oklch(0.78 0.003 265);--ring:oklch(0.45 0.005 265);" +
34
+ "--warning:oklch(0.65 0.18 85)}}",
35
+ "*{box-sizing:border-box}",
36
+ "body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1.5rem;" +
37
+ "background:var(--background);color:var(--foreground);" +
38
+ "font:15px/1.55 ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif}",
39
+ "main{width:100%;max-width:28rem;background:var(--card);border:1px solid var(--border);" +
40
+ "border-radius:var(--radius);padding:2rem}",
41
+ ".brand{margin:0 0 1.25rem;font-size:.8rem;font-weight:600;letter-spacing:.06em;text-transform:uppercase;" +
42
+ "color:var(--muted-foreground)}",
43
+ "h1{margin:0 0 .5rem;font-size:1.25rem;font-weight:600;letter-spacing:-.01em}",
44
+ "p{margin:0 0 1rem}.muted{color:var(--muted-foreground);font-size:.875rem}",
45
+ "code{font-family:ui-monospace,'SF Mono',monospace;font-size:.8125rem;background:var(--secondary);" +
46
+ "padding:.1rem .35rem;border-radius:.375rem}",
47
+ ".alert{border:1px solid var(--warning);border-radius:.5rem;padding:.75rem;color:var(--warning);font-size:.875rem}",
48
+ "fieldset{border:0;margin:0 0 1.5rem;padding:0}",
49
+ "legend{font-size:.8rem;font-weight:600;color:var(--muted-foreground);margin-bottom:.5rem;padding:0}",
50
+ ".org{display:flex;align-items:center;gap:.75rem;padding:.75rem .875rem;margin-bottom:.5rem;" +
51
+ "border:1px solid var(--border);border-radius:.5rem;cursor:pointer}",
52
+ ".org:hover{border-color:var(--border-hover);background:var(--card-hover)}",
53
+ ".org:has(input:checked){border-color:var(--primary)}",
54
+ ".org input{accent-color:var(--primary);margin:0}",
55
+ ".name{flex:1;font-weight:500}",
56
+ ".role{font-size:.75rem;color:var(--muted-foreground);background:var(--secondary);" +
57
+ "padding:.125rem .5rem;border-radius:999px}",
58
+ ".actions{display:flex;gap:.5rem}",
59
+ "button{flex:1;font:inherit;font-weight:500;padding:.625rem 1rem;border-radius:.5rem;cursor:pointer;" +
60
+ "border:1px solid var(--border);background:transparent;color:var(--foreground)}",
61
+ "button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}",
62
+ "button:hover{opacity:.9}button:focus-visible,.org:focus-within{outline:2px solid var(--ring);outline-offset:2px}",
63
+ ].join("");
64
+ /**
65
+ * The `form-action` sources for the redirect URIs of THIS authorization. Chromium enforces
66
+ * `form-action` on the redirect that follows a form POST, and POST /authorize/org answers a
67
+ * 302 to the client's `redirect_uri` (always another origin), so that destination MUST be
68
+ * listed or the client never receives its code. Built from `new URL(...)`, never from raw
69
+ * input: an http(s) URI contributes its origin (loopback keeps its port), any other URI its
70
+ * scheme (`cursor:`). Fail closed: no URI, or one that does not parse, throws; the page is
71
+ * never rendered with `'self'` alone.
72
+ */
73
+ function formActionSources(redirectUris) {
74
+ if (redirectUris.length === 0)
75
+ throw new Error("picker CSP: no redirect_uri to permit");
76
+ const sources = new Set();
77
+ for (const raw of redirectUris) {
78
+ let url;
79
+ try {
80
+ url = new URL(raw);
81
+ }
82
+ catch {
83
+ throw new Error("picker CSP: redirect_uri does not parse");
84
+ }
85
+ sources.add(url.protocol === "https:" || url.protocol === "http:" ? url.origin : url.protocol);
86
+ }
87
+ return [...sources];
88
+ }
89
+ /**
90
+ * CSP for the picker page: no scripts, no external assets, one hashed inline style, and forms
91
+ * that post to this server and are redirected to the client's `redirect_uri` only.
92
+ */
93
+ export async function pickerContentSecurityPolicy(redirectUris) {
94
+ const formAction = ["'self'", ...formActionSources(redirectUris)].join(" ");
21
95
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(PICKER_STYLE));
22
96
  const hash = btoa(String.fromCharCode(...new Uint8Array(digest)));
23
97
  return [
24
98
  "default-src 'none'",
25
99
  `style-src 'sha256-${hash}'`,
26
100
  "base-uri 'none'",
101
+ `form-action ${formAction}`,
27
102
  "frame-ancestors 'none'",
28
103
  ].join("; ");
29
104
  }
30
105
  export function renderOrgPicker(model, locale, consent) {
31
106
  const destination = escapeHtml(model.redirectOrigin);
32
107
  const unverified = consent.unverified
33
- ? `<p role="alert"><strong>${escapeHtml(t(locale, "oauth.pickerUnverified"))}</strong></p>\n`
108
+ ? `<p class="alert" role="alert"><strong>${escapeHtml(t(locale, "oauth.pickerUnverified"))}</strong></p>\n`
34
109
  : "";
35
110
  const clientName = model.client.clientName ?? model.client.clientId;
36
111
  const only = model.organizations.length === 1;
37
112
  const orgs = model.organizations
38
- .map((o, i) => `<label><input type="radio" name="orgId" value="${escapeHtml(o.id)}" required${only && i === 0 ? " checked" : ""}> ${escapeHtml(o.name)} <small>(${escapeHtml(o.role)})</small></label>`)
113
+ .map((o, i) => `<label class="org"><input type="radio" name="orgId" value="${escapeHtml(o.id)}" required${only && i === 0 ? " checked" : ""}><span class="name">${escapeHtml(o.name)}</span><span class="role">${escapeHtml(o.role.replace(/^org:/, ""))}</span></label>`)
39
114
  .join("\n");
40
- const title = escapeHtml(t(locale, "oauth.pickerTitle").replace("{client}", clientName));
41
- const intro = escapeHtml(t(locale, "oauth.pickerIntro").replaceAll("{client}", clientName));
115
+ const title = escapeHtml(t(locale, "oauth.pickerTitle").replace("{client}", () => clientName));
116
+ const intro = escapeHtml(t(locale, "oauth.pickerIntro").replaceAll("{client}", () => clientName));
117
+ const brand = consent.brandName ? `<p class="brand">${escapeHtml(consent.brandName)}</p>\n` : "";
42
118
  return `<!doctype html>
43
119
  <html lang="${locale}">
44
120
  <head>
45
121
  <meta charset="utf-8">
46
122
  <meta name="viewport" content="width=device-width, initial-scale=1">
47
123
  <meta name="referrer" content="no-referrer">
124
+ <meta name="color-scheme" content="dark light">
48
125
  <title>${title}</title>
49
126
  <style>${PICKER_STYLE}</style>
50
127
  </head>
51
128
  <body>
52
129
  <main>
53
- <h1>${title}</h1>
130
+ ${brand}<h1>${title}</h1>
54
131
  <p>${intro}</p>
55
- <p>${escapeHtml(t(locale, "oauth.pickerDestination"))} <strong>${destination}</strong><br>
132
+ <p class="muted">${escapeHtml(t(locale, "oauth.pickerDestination"))} <strong>${destination}</strong><br>
56
133
  ${escapeHtml(t(locale, "oauth.pickerScope"))} <code>${escapeHtml(model.scope)}</code></p>
57
134
  ${unverified}<form method="post" action="/authorize/org">
58
135
  <input type="hidden" name="state" value="${escapeHtml(model.state)}">
@@ -61,8 +138,8 @@ ${unverified}<form method="post" action="/authorize/org">
61
138
  <legend>${escapeHtml(t(locale, "oauth.pickerOrganisation"))}</legend>
62
139
  ${orgs}
63
140
  </fieldset>
64
- <p>
65
- <button type="submit" name="approved" value="true">${escapeHtml(t(locale, "oauth.approve"))}</button>
141
+ <p class="actions">
142
+ <button class="primary" type="submit" name="approved" value="true">${escapeHtml(t(locale, "oauth.approve"))}</button>
66
143
  <button type="submit" name="approved" value="false" formnovalidate>${escapeHtml(t(locale, "oauth.deny"))}</button>
67
144
  </p>
68
145
  </form>
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Rate limiter of the unauthenticated `POST /register`.
3
+ *
4
+ * Keys are NORMALISED so one host cannot multiply its budget: an IPv6 client is keyed on its
5
+ * /64 prefix (a host owns a whole /64, so per-address keys are free), an IPv4-mapped IPv6
6
+ * address is keyed as the IPv4 address it carries, IPv4 keeps the full address. Parsing is
7
+ * `node:net`'s (`isIP`) plus the group reader the SSRF guard already uses.
8
+ *
9
+ * A /48 holds 65536 /64s, each with its own budget, so an IPv6 client is ALSO counted against
10
+ * its /48 when a `/48` budget is configured: a request passes only while both its /64 and its
11
+ * /48 are under their limits. A request already refused by its /64 is not charged to the /48. IPv4 and IPv4-mapped addresses have no /48 and are unaffected.
12
+ *
13
+ * Memory is bounded without ever handing an attacker a reset. When the table is full the
14
+ * least-recently-used key is evicted, but keys still inside a window and not yet limited go
15
+ * first: a key that already hit the limit is the last thing dropped, so a flood of fresh keys
16
+ * cannot wipe a counter that is doing its job. (A bare `clear()` did exactly that.)
17
+ */
18
+ /** The counter key of a client address. A value that is not an IP literal is returned as is. */
19
+ export declare function normaliseClientKey(address: string): string;
20
+ /** The /48 aggregate key of a native IPv6 address, or null for IPv4, IPv4-mapped or non-IP. */
21
+ export declare function prefix48Key(address: string): string | null;
22
+ export interface RegisterLimiter {
23
+ /** Counts one request for `address`; false when it is over the limit for the window. */
24
+ allow(address: string): boolean;
25
+ /** Keys currently tracked (tests and diagnostics). */
26
+ size(): number;
27
+ }
28
+ export declare function createRegisterLimiter(options: {
29
+ limitPerMinute: number;
30
+ /** Aggregate budget of every /64 inside one IPv6 /48; absent means no /48 budget. */
31
+ prefix48LimitPerMinute?: number;
32
+ maxKeys?: number;
33
+ now: () => number;
34
+ }): RegisterLimiter;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Rate limiter of the unauthenticated `POST /register`.
3
+ *
4
+ * Keys are NORMALISED so one host cannot multiply its budget: an IPv6 client is keyed on its
5
+ * /64 prefix (a host owns a whole /64, so per-address keys are free), an IPv4-mapped IPv6
6
+ * address is keyed as the IPv4 address it carries, IPv4 keeps the full address. Parsing is
7
+ * `node:net`'s (`isIP`) plus the group reader the SSRF guard already uses.
8
+ *
9
+ * A /48 holds 65536 /64s, each with its own budget, so an IPv6 client is ALSO counted against
10
+ * its /48 when a `/48` budget is configured: a request passes only while both its /64 and its
11
+ * /48 are under their limits. A request already refused by its /64 is not charged to the /48. IPv4 and IPv4-mapped addresses have no /48 and are unaffected.
12
+ *
13
+ * Memory is bounded without ever handing an attacker a reset. When the table is full the
14
+ * least-recently-used key is evicted, but keys still inside a window and not yet limited go
15
+ * first: a key that already hit the limit is the last thing dropped, so a flood of fresh keys
16
+ * cannot wipe a counter that is doing its job. (A bare `clear()` did exactly that.)
17
+ */
18
+ import { isIP } from "node:net";
19
+ import { ipv6Groups } from "./clientRegistry.js";
20
+ const WINDOW_MS = 60_000;
21
+ /** The counter key of a client address. A value that is not an IP literal is returned as is. */
22
+ export function normaliseClientKey(address) {
23
+ const bare = address.replace(/^\[|\]$/g, "").split("%")[0] ?? address;
24
+ const version = isIP(bare);
25
+ if (version === 4)
26
+ return bare;
27
+ if (version !== 6)
28
+ return address;
29
+ const g = ipv6Groups(bare);
30
+ const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff;
31
+ if (mapped)
32
+ return `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
33
+ return `${g
34
+ .slice(0, 4)
35
+ .map((x) => x.toString(16))
36
+ .join(":")}::/64`;
37
+ }
38
+ /** The /48 aggregate key of a native IPv6 address, or null for IPv4, IPv4-mapped or non-IP. */
39
+ export function prefix48Key(address) {
40
+ const bare = address.replace(/^\[|\]$/g, "").split("%")[0] ?? address;
41
+ if (isIP(bare) !== 6)
42
+ return null;
43
+ const g = ipv6Groups(bare);
44
+ const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff;
45
+ if (mapped)
46
+ return null;
47
+ return `${g
48
+ .slice(0, 3)
49
+ .map((x) => x.toString(16))
50
+ .join(":")}::/48`;
51
+ }
52
+ export function createRegisterLimiter(options) {
53
+ const { limitPerMinute, prefix48LimitPerMinute, now } = options;
54
+ const maxKeys = options.maxKeys ?? 5000;
55
+ // Two LRU tables (Map iteration order = insertion order, re-inserted on every hit): keys
56
+ // still allowed, and keys already limited. Eviction drains the first before the second.
57
+ const open = new Map();
58
+ const limited = new Map();
59
+ const evictOne = () => {
60
+ const table = open.size > 0 ? open : limited;
61
+ const oldest = table.keys().next();
62
+ if (!oldest.done)
63
+ table.delete(oldest.value);
64
+ };
65
+ /** Counts one hit on `key`; true when that key is now over `limit` for its window. */
66
+ const bump = (key, limit, t0) => {
67
+ const prior = open.get(key) ?? limited.get(key);
68
+ const expired = prior !== undefined && t0 - prior.windowStart >= WINDOW_MS;
69
+ open.delete(key);
70
+ limited.delete(key);
71
+ if (!prior || expired) {
72
+ while (open.size + limited.size >= maxKeys)
73
+ evictOne();
74
+ const fresh = { windowStart: t0, count: 1 };
75
+ (fresh.count > limit ? limited : open).set(key, fresh);
76
+ return fresh.count > limit;
77
+ }
78
+ prior.count += 1;
79
+ const over = prior.count > limit;
80
+ (over ? limited : open).set(key, prior);
81
+ return over;
82
+ };
83
+ return {
84
+ allow(address) {
85
+ const t0 = now();
86
+ const overHost = bump(normaliseClientKey(address), limitPerMinute, t0);
87
+ // A host already over its own limit is refused without spending the shared /48 budget,
88
+ // so one hammering /64 cannot lock out the other /64s of its /48.
89
+ const wide = overHost || prefix48LimitPerMinute === undefined ? null : prefix48Key(address);
90
+ const overWide = wide === null ? false : bump(wide, prefix48LimitPerMinute, t0);
91
+ return !overHost && !overWide;
92
+ },
93
+ size: () => open.size + limited.size,
94
+ };
95
+ }
@@ -16,10 +16,14 @@ export declare const OAUTH_ENV: {
16
16
  readonly signingJwk: "MCP_OAUTH_SIGNING_JWK";
17
17
  /** Number of trusted reverse proxies in front of this server (default 0). */
18
18
  readonly trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT";
19
+ /** /register budget shared by every /64 inside one IPv6 /48, per minute (default 100). */
20
+ readonly registerLimitPer48: "MCP_REGISTER_LIMIT_PER_48_PER_MINUTE";
19
21
  /** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
20
22
  readonly registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS";
21
23
  /** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
22
24
  readonly allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES";
25
+ /** Optional display name shown above the consent page title (default: none). */
26
+ readonly brandName: "AUTHORIZE_BRAND_NAME";
23
27
  };
24
28
  /** Scope granted when a client names none: MCP access plus the OIDC subject. */
25
29
  export declare const MCP_SCOPE = "mcp:full";
@@ -16,10 +16,14 @@ export const OAUTH_ENV = {
16
16
  signingJwk: "MCP_OAUTH_SIGNING_JWK",
17
17
  /** Number of trusted reverse proxies in front of this server (default 0). */
18
18
  trustedProxyCount: "MCP_TRUSTED_PROXY_COUNT",
19
+ /** /register budget shared by every /64 inside one IPv6 /48, per minute (default 100). */
20
+ registerLimitPer48: "MCP_REGISTER_LIMIT_PER_48_PER_MINUTE",
19
21
  /** Comma-separated browser origins allowed to call /register cross-origin (default: none). */
20
22
  registerCorsOrigins: "MCP_REGISTER_CORS_ORIGINS",
21
23
  /** Exactly "1" lets NODE_ENV=production run on the in-memory stores (single instance only). */
22
24
  allowInMemoryStores: "MCP_ALLOW_IN_MEMORY_STORES",
25
+ /** Optional display name shown above the consent page title (default: none). */
26
+ brandName: "AUTHORIZE_BRAND_NAME",
23
27
  };
24
28
  /** Scope granted when a client names none: MCP access plus the OIDC subject. */
25
29
  export const MCP_SCOPE = "mcp:full";