@coffre/core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/schemas.js CHANGED
@@ -1,12 +1,40 @@
1
+ import { PROJECT_PAGES } from "./pages.js";
1
2
  import { z } from "zod";
2
3
  //#region src/schemas.ts
3
4
  const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
5
+ /** An environment's slug: any slug but the name of one of its project's pages. */
6
+ const environmentSlug = slug.refine((value) => !PROJECT_PAGES.includes(value), { error: (issue) => `"${String(issue.input)}" is taken by a project's page, /projects/<project>/${String(issue.input)}: an environment cannot have it` });
7
+ /**
8
+ * A deleted project or environment keeps its row as a tombstone, under a
9
+ * slug no live place can hold: `market~deleted-2026-10-05`. The `~` is the
10
+ * whole rule: a place is deleted when its slug, or its project's, holds one.
11
+ * `@coffre/db/dialect`'s `tombstone()` is the same rule in SQL.
12
+ */
13
+ const TOMBSTONE = "~";
14
+ function isTombstone(slug) {
15
+ return slug.includes("~");
16
+ }
17
+ /** The slug a place deleted on `day` keeps: the `n`th that day is `market~deleted-2026-10-05-<n>`. */
18
+ function tombstoneOf(slug, day, n = 1) {
19
+ const base = `${slug}~deleted-${day.toISOString().slice(0, 10)}`;
20
+ return n === 1 ? base : `${base}-${n}`;
21
+ }
4
22
  const displayName = z.string().trim().min(1).max(120);
23
+ /** A folder: 1 to 64 characters, no `/`, no control character, no space at either end (the tables check it too). */
24
+ const folderName = z.string().min(1).max(64).refine((name) => name === name.trim(), "a folder name cannot start or end with a space").refine((name) => !/[/\p{Cc}]/u.test(name), "a folder name cannot hold \"/\" or a control character");
5
25
  const secretKey = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/);
26
+ /**
27
+ * A member as people read it, in a message or on a page: a service account
28
+ * as `service:<name>`, though the API, the vault and the log keep the
29
+ * `token:<name>` their signed entries hold.
30
+ */
31
+ function shownMember(member) {
32
+ return member.startsWith("token:") ? `service:${member.slice(6)}` : member;
33
+ }
6
34
  const principalType = z.enum(["user", "service"]);
7
35
  const principalId = z.string().trim().min(1).max(320);
8
36
  const instanceRole = z.enum(["user", "owner"]);
9
37
  const grantId = z.string().uuid();
10
38
  const emailAddress = z.string().email().max(320);
11
39
  //#endregion
12
- export { displayName, emailAddress, grantId, instanceRole, principalId, principalType, secretKey, slug };
40
+ export { TOMBSTONE, displayName, emailAddress, environmentSlug, folderName, grantId, instanceRole, isTombstone, principalId, principalType, secretKey, shownMember, slug, tombstoneOf };
package/dist/vault.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Role } from "./access.js";
1
+ import { GrantPlace, Role } from "./access.js";
2
2
  //#region src/access-fault.d.ts
3
3
  /**
4
4
  * Why the vault's members and grants do not follow from its log, as facts
@@ -48,15 +48,14 @@ type AccessFault =
48
48
  principal: string;
49
49
  why: 'mac' | 'stale';
50
50
  };
51
- type FaultGrant = {
51
+ type FaultGrant = GrantPlace & {
52
52
  principal: string;
53
- projectId: string;
54
- environmentId: string | null;
55
53
  role: string;
56
54
  };
57
55
  /** How to name a principal (`user:ada@…`) and a place; ids by default. */
58
56
  type FaultNames = {
59
57
  principal?: (principal: string) => string;
58
+ /** Asked only of a project's or an environment's place: a grant on every project is named by its path. */
60
59
  place?: (projectId: string, environmentId: string | null) => string;
61
60
  };
62
61
  /** The fault in a sentence, with whatever names the caller can give. */
@@ -114,6 +113,28 @@ export interface Vault {
114
113
  wrapped: WrappedKey[];
115
114
  seqs: number[];
116
115
  }>>;
116
+ /**
117
+ * Make secrets references to others: whoever can read the holder's
118
+ * environment reads the source through it (docs/design/environments.md).
119
+ * The vault checks that `principal` reads each source and writes each
120
+ * holder, by their own grants, and logs a `reference.create` per item:
121
+ * that entry, under the vault's MAC, is the reference. `seqs` are the
122
+ * entries, which the app stores beside each reference and names when it
123
+ * reads through one (`UnwrapInput`'s `via`). All, or none.
124
+ */
125
+ reference(input: ReferenceInput): Promise<Outcome<{
126
+ seqs: number[];
127
+ }>>;
128
+ /**
129
+ * End references, each with a `reference.end` naming its creation:
130
+ * `replaced` when the holder gets a value of its own, which takes write on
131
+ * the holder; `broken` otherwise, which takes write on the holder, or
132
+ * managing the source's project's access. An ended reference reads no
133
+ * more, whatever any row says. All, or none.
134
+ */
135
+ endReferences(input: EndReferencesInput): Promise<Outcome<{
136
+ seqs: number[];
137
+ }>>;
117
138
  /**
118
139
  * What one principal holds right now: the app asks once per request. A
119
140
  * decision, its rows checked: lists of who holds what read the rows
@@ -203,8 +224,15 @@ export type RefusalCode =
203
224
  'expired' |
204
225
  /** Too many unwraps in the window; see `BulkLimit`. */
205
226
  'bulk_limit' |
206
- /** The wrapped key does not belong to the secret it was presented as. */
227
+ /** The wrapped key does not belong to the secret it was presented as, or a reference is not the vault's. */
207
228
  'bad_claim' |
229
+ /**
230
+ * The project or environment was deleted for good: nothing under it is
231
+ * opened or wrapped again, whatever grant would cover it.
232
+ */
233
+ 'deleted' |
234
+ /** The reference read through was ended: broken, or replaced by a value. */
235
+ 'ended' |
208
236
  /** Not allowed to manage access, members or the log. */
209
237
  'not_allowed' |
210
238
  /** Root admins come from the vault's configuration and cannot be changed. */
@@ -266,8 +294,8 @@ export type KeyChecks = {
266
294
  seq: number;
267
295
  })[];
268
296
  };
269
- /** Why someone reads: shown in the log, and the same rules apply to each. */
270
- export type Purpose = 'reveal' | 'run' | 'compare';
297
+ /** Why someone reads: shown in the log, and the same rules apply to each. A fork reads to `copy`. */
298
+ export type Purpose = 'reveal' | 'run' | 'compare' | 'copy';
271
299
  /** Ties the vault's entries to the app's request and audit rows. */
272
300
  type Correlation = {
273
301
  requestId?: string | null;
@@ -286,10 +314,45 @@ type Correlation = {
286
314
  export type UnwrapInput = Correlation & {
287
315
  principal: string;
288
316
  purpose: Purpose;
317
+ /**
318
+ * `via` reads a source's current version through a reference: the vault
319
+ * checks the reader's grants on the holder's environment, as its
320
+ * `reference.create` entry names it, not on the source's.
321
+ */
289
322
  items: {
290
323
  secretVersionId: string;
324
+ via?: Via;
325
+ }[];
326
+ };
327
+ /** A reference, as a read names it: its id, and the seq of the vault's `reference.create` entry. */
328
+ export type Via = {
329
+ reference: string;
330
+ seq: number;
331
+ };
332
+ /** Where a reference is held: the holder's ids, and its path for the log. A new holder's row may not exist yet. */
333
+ export type HolderRef = {
334
+ projectId: string;
335
+ environmentId: string;
336
+ secretId: string;
337
+ path: string;
338
+ };
339
+ export type ReferenceInput = Correlation & {
340
+ principal: string;
341
+ /** `id` is the reference's, a fresh UUID; the vault reads the source by its id. */
342
+ items: {
343
+ id: string;
344
+ holder: HolderRef;
345
+ source: {
346
+ secretId: string;
347
+ };
291
348
  }[];
292
349
  };
350
+ export type EndReferencesInput = Correlation & {
351
+ principal: string;
352
+ /** `abandoned`: sealed for a write that stored nothing, so that no row written later revives it. */
353
+ reason: 'replaced' | 'broken' | 'abandoned';
354
+ items: Via[];
355
+ };
293
356
  export type WrapInput = Correlation & {
294
357
  principal: string;
295
358
  /** `key` is a fresh 32-byte data key, base64. */
@@ -306,10 +369,12 @@ export type RewrapInput = Correlation & {
306
369
  secretVersionId: string;
307
370
  }[];
308
371
  };
309
- export type Grant = {
310
- projectId: string;
311
- /** Null for the whole project. */
312
- environmentId: string | null;
372
+ /**
373
+ * A grant: on a project, one of its environments (`projectId` is the
374
+ * environment's project), every project, or one environment slug in every
375
+ * project (`GrantPlace` in @coffre/core/access).
376
+ */
377
+ export type Grant = GrantPlace & {
313
378
  role: Role;
314
379
  /** ISO 8601, or null for no end. */
315
380
  expiresAt: string | null;
@@ -335,10 +400,13 @@ export type Access = {
335
400
  since: string | null;
336
401
  by: string | null;
337
402
  };
338
- /** One place: a role to hold there, with an optional end, or null for none. */
339
- export type GrantChange = {
340
- projectId: string;
341
- environmentId: string | null;
403
+ /**
404
+ * One place: a role to hold there, with an optional end, or null for none.
405
+ * An environment's place names its project too; `environmentSlug` left out
406
+ * is null.
407
+ */
408
+ export type GrantChange = Omit<GrantPlace, 'environmentSlug'> & {
409
+ environmentSlug?: string | null;
342
410
  role: Role | null;
343
411
  expiresAt: string | null;
344
412
  };
package/dist/vault.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { everyProjectPath, grantKind } from "./access.js";
1
2
  //#region src/checkpoint.ts
2
3
  /** What a checkpoint's signature covers: the prefix of the log up to entry `seq`, and when. */
3
4
  function checkpointMessage(checkpoint) {
@@ -23,7 +24,11 @@ async function checkpointVerifier(publicKey) {
23
24
  function describeAccessFault(fault, names = {}) {
24
25
  const who = names.principal ?? ((principal) => principal);
25
26
  const where = names.place ?? ((projectId, environmentId) => environmentId === null ? projectId : `${projectId}/${environmentId}`);
26
- const grant = ({ principal, projectId, environmentId, role }) => `${who(principal)} as ${role} on ${where(projectId, environmentId)}`;
27
+ const grant = (held) => {
28
+ const { principal, projectId, environmentId, environmentSlug, role } = held;
29
+ const place = grantKind(held) === "every-project" ? everyProjectPath(environmentSlug) : where(projectId ?? "no project", environmentId);
30
+ return `${who(principal)} as ${role} on ${place}`;
31
+ };
27
32
  switch (fault.kind) {
28
33
  case "unadmitted-change": return `entry ${fault.seq} changes ${who(fault.principal)}, whom the log never admitted`;
29
34
  case "unlogged-member": return `the store has ${who(fault.principal)} as a member, whom the log never admitted`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/core",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "What coffre's packages share: roles, envelopes, identity, the audit chain and the vault's contract.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -47,6 +47,11 @@
47
47
  "types": "./dist/kek.d.ts",
48
48
  "default": "./dist/kek.js"
49
49
  },
50
+ "./mcp": {
51
+ "coffre:source": "./src/mcp.ts",
52
+ "types": "./dist/mcp.d.ts",
53
+ "default": "./dist/mcp.js"
54
+ },
50
55
  "./schemas": {
51
56
  "coffre:source": "./src/schemas.ts",
52
57
  "types": "./dist/schemas.d.ts",
@@ -79,7 +84,7 @@
79
84
  "@types/node": "26.1.1",
80
85
  "tsdown": "0.23.0",
81
86
  "typescript": "5.9.3",
82
- "@coffre/conformance": "0.2.0"
87
+ "@coffre/conformance": "0.4.0"
83
88
  },
84
89
  "scripts": {
85
90
  "build": "tsdown",
@@ -1,3 +1,5 @@
1
+ import { everyProjectPath, grantKind, type GrantPlace } from './access.ts';
2
+
1
3
  /**
2
4
  * Why the vault's members and grants do not follow from its log, as facts
3
5
  * rather than a sentence. The vault knows only ids; the app knows what the
@@ -23,11 +25,12 @@ export type AccessFault =
23
25
  */
24
26
  | { kind: 'tampered-member'; principal: string; why: 'mac' | 'stale' };
25
27
 
26
- export type FaultGrant = { principal: string; projectId: string; environmentId: string | null; role: string };
28
+ export type FaultGrant = GrantPlace & { principal: string; role: string };
27
29
 
28
30
  /** How to name a principal (`user:ada@…`) and a place; ids by default. */
29
31
  export type FaultNames = {
30
32
  principal?: (principal: string) => string;
33
+ /** Asked only of a project's or an environment's place: a grant on every project is named by its path. */
31
34
  place?: (projectId: string, environmentId: string | null) => string;
32
35
  };
33
36
 
@@ -38,8 +41,11 @@ export function describeAccessFault(fault: AccessFault, names: FaultNames = {}):
38
41
  names.place ??
39
42
  ((projectId: string, environmentId: string | null) =>
40
43
  environmentId === null ? projectId : `${projectId}/${environmentId}`);
41
- const grant = ({ principal, projectId, environmentId, role }: FaultGrant) =>
42
- `${who(principal)} as ${role} on ${where(projectId, environmentId)}`;
44
+ const grant = (held: FaultGrant) => {
45
+ const { principal, projectId, environmentId, environmentSlug, role } = held;
46
+ const place = grantKind(held) === 'every-project' ? everyProjectPath(environmentSlug) : where(projectId ?? 'no project', environmentId);
47
+ return `${who(principal)} as ${role} on ${place}`;
48
+ };
43
49
  switch (fault.kind) {
44
50
  case 'unadmitted-change':
45
51
  return `entry ${fault.seq} changes ${who(fault.principal)}, whom the log never admitted`;
package/src/access.ts CHANGED
@@ -85,8 +85,51 @@ export function assignableToEnvironment(role: Role): boolean {
85
85
  );
86
86
  }
87
87
 
88
- /** A place a permission applies to: a project, or one of its environments. */
89
- export type Place = { projectId: string; environmentId?: string | null };
88
+ /**
89
+ * A place a permission applies to: a project, or one of its environments,
90
+ * named by its slug too, which grants on every project match by. A slug
91
+ * not read (null) matches no grant on a slug.
92
+ */
93
+ export type Place =
94
+ | { projectId: string; environmentId?: null }
95
+ | { projectId: string; environmentId: string; environmentSlug: string | null };
96
+
97
+ /**
98
+ * Where a grant applies, by the fields it fills (`grantKind`):
99
+ *
100
+ * market projectId
101
+ * market/dev projectId and environmentId
102
+ * * neither: every project, the ones created later too
103
+ * *, on dev environmentSlug only: the environment of that slug in every project
104
+ */
105
+ export type GrantPlace = {
106
+ /** The project, also of an environment's grant; null only on every project. */
107
+ projectId: string | null;
108
+ environmentId: string | null;
109
+ /** On every project, the one environment slug it covers; null for all of them, and on a project's grant. */
110
+ environmentSlug: string | null;
111
+ };
112
+
113
+ /**
114
+ * What a grant's fields say it is on, or null when they say nothing
115
+ * coherent: an environment without its project, as a grant whose
116
+ * environment row is gone reads, or a slug beside an id. A grant is on
117
+ * every project only when it names no place at all; anything else that
118
+ * lacks a field covers nothing.
119
+ */
120
+ export function grantKind(grant: GrantPlace): 'project' | 'environment' | 'every-project' | null {
121
+ if (grant.environmentId !== null) return grant.projectId !== null && grant.environmentSlug === null ? 'environment' : null;
122
+ if (grant.projectId !== null) return grant.environmentSlug === null ? 'project' : null;
123
+ return 'every-project';
124
+ }
125
+
126
+ /** Every project, as a path names it. */
127
+ export const EVERY_PROJECT = '*';
128
+
129
+ /** A grant on every project as a path: `*`, or `*` and the environment slug it covers in each. */
130
+ export function everyProjectPath(environmentSlug: string | null): string {
131
+ return environmentSlug === null ? EVERY_PROJECT : `${EVERY_PROJECT}/${environmentSlug}`;
132
+ }
90
133
 
91
134
  /**
92
135
  * What someone holds: everything a permission check reads. The app builds
@@ -100,33 +143,54 @@ export type Holdings = {
100
143
  /** Root admins and active users with the instance `owner` role. */
101
144
  isOwner: boolean;
102
145
  /** Live grants only. */
103
- grants: readonly { projectId: string; environmentId: string | null; role: Role }[];
146
+ grants: readonly (GrantPlace & { role: Role })[];
104
147
  };
105
148
 
106
149
  /**
107
- * Whether `holder` may do `permission` at `place`.
150
+ * Whether a grant at `grant` reaches `place`:
108
151
  *
109
- * on a project its project grant; owners also manage every project
110
- * on an environment its environment grant or its project's grant
152
+ * market market and each of its environments
153
+ * market/dev market/dev
154
+ * * every project and every environment
155
+ * *, on dev each environment whose slug is dev, in every project
111
156
  *
112
- * An environment grant never reaches up to the project: `developer` on
113
- * `market/prod` does not let anyone rename `market`.
157
+ * An environment's grant never reaches up to its project, whether it names
158
+ * the environment or its slug.
159
+ */
160
+ export function covers(grant: GrantPlace, place: Place): boolean {
161
+ switch (grantKind(grant)) {
162
+ case 'environment':
163
+ return grant.projectId === place.projectId && grant.environmentId === (place.environmentId ?? null);
164
+ case 'project':
165
+ return grant.projectId === place.projectId;
166
+ case 'every-project':
167
+ return grant.environmentSlug === null || (place.environmentId != null && place.environmentSlug !== null && place.environmentSlug === grant.environmentSlug);
168
+ case null:
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Whether `holder` may do `permission` at `place`: a grant that covers it
175
+ * gives the role's permissions there, and grants add up. Owners also manage
176
+ * every project. `developer` on `market/prod` does not let anyone rename
177
+ * `market`.
114
178
  */
115
179
  export function allows(holder: Holdings, permission: Permission, place: Place): boolean {
116
180
  if (holder.isRootAdmin) return true;
117
- const environmentId = place.environmentId ?? null;
118
- if (environmentId === null && holder.isOwner && PROJECT_ONLY_PERMISSIONS.includes(permission)) {
181
+ if (place.environmentId == null && holder.isOwner && PROJECT_ONLY_PERMISSIONS.includes(permission)) {
119
182
  return true;
120
183
  }
121
- return holder.grants.some(
122
- (grant) =>
123
- grant.projectId === place.projectId &&
124
- (grant.environmentId === null || grant.environmentId === environmentId) &&
125
- roleGrants(grant.role, permission),
126
- );
184
+ return holder.grants.some((grant) => covers(grant, place) && roleGrants(grant.role, permission));
127
185
  }
128
186
 
129
- /** Whether the actor may manage grants on this project. */
130
- export function mayManageAccess(actor: Holdings, place: Place): boolean {
131
- return allows(actor, 'grant.manage', { projectId: place.projectId });
187
+ /**
188
+ * Whether the actor may manage grants at this place: on a project or its
189
+ * environments, with `grant.manage` on the project; on every project, as an
190
+ * instance owner or a root admin only.
191
+ */
192
+ export function mayManageAccess(actor: Holdings, place: GrantPlace): boolean {
193
+ const kind = grantKind(place);
194
+ if (kind === 'every-project') return actor.isOwner || actor.isRootAdmin;
195
+ return kind !== null && place.projectId !== null && allows(actor, 'grant.manage', { projectId: place.projectId });
132
196
  }
@@ -1,4 +1,4 @@
1
- import { defineSignin, type SigninConfig, type WorkloadsOptions } from './signin/config.ts';
1
+ import { defineSignin, type McpOptions, type SigninConfig, type WorkloadsOptions } from './signin/config.ts';
2
2
  import type { SigninProvider } from './signin/types.ts';
3
3
  import type { AccessVerifierConfig } from './types.ts';
4
4
 
@@ -101,6 +101,8 @@ export type SigninOptions = {
101
101
  cliSessionDays?: number;
102
102
  /** Lets CI runs sign in as services with their platform's ID token: `{}` turns it on. */
103
103
  workloads?: WorkloadsOptions;
104
+ /** Lets MCP clients, Claude among them, act as the people who connect them (docs/mcp.md). */
105
+ mcp?: McpOptions;
104
106
  };
105
107
 
106
108
  /**
@@ -125,5 +127,6 @@ function toSignin(options: SigninOptions) {
125
127
  browserSessionHours: options.browserSessionHours,
126
128
  cliSessionDays: options.cliSessionDays,
127
129
  workloads: options.workloads,
130
+ mcp: options.mcp,
128
131
  };
129
132
  }
@@ -70,6 +70,8 @@ export type SigninConfig = {
70
70
  cliSessionDays: number;
71
71
  /** CI runs signing in as services with their platform's ID token, when on (`workloads.ts`). */
72
72
  workloads: WorkloadsConfig | null;
73
+ /** MCP clients acting as the people who connect them, when on (docs/design/mcp.md). */
74
+ mcp: McpConfig | null;
73
75
  };
74
76
 
75
77
  /**
@@ -102,6 +104,32 @@ export type WorkloadLimits = { perSource: RateLimiter; total: RateLimiter };
102
104
 
103
105
  export type WorkloadsConfig = { allowLoopback: boolean; limits: WorkloadLimits };
104
106
 
107
+ /**
108
+ * MCP clients, Claude among them, acting as the people who connect them,
109
+ * with coffre as their OAuth authorization server (docs/design/mcp.md). Off
110
+ * unless the deployment turns it on.
111
+ */
112
+ export type McpOptions = {
113
+ /**
114
+ * The limits every unauthenticated OAuth request passes before coffre does
115
+ * any work for it, one per source address and one for all together, and
116
+ * the one each connection's tool calls pass. On Workers, three
117
+ * rate-limiting bindings; on Node, `processLimits()` from
118
+ * `@coffre/server/node`. Required.
119
+ */
120
+ limits: McpLimits;
121
+ /**
122
+ * Lets a client's metadata document be plain HTTP on loopback: for local
123
+ * development and conformance. Refused unless the public URL is loopback
124
+ * too.
125
+ */
126
+ allowLoopbackClientsForDevelopment?: boolean;
127
+ };
128
+
129
+ export type McpLimits = { perSource: RateLimiter; perConnection: RateLimiter; total: RateLimiter };
130
+
131
+ export type McpConfig = { allowLoopback: boolean; limits: McpLimits };
132
+
105
133
  const PROVIDER_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
106
134
  const BRANDS: readonly SigninBrand[] = ['github', 'google', 'microsoft', 'oidc'];
107
135
 
@@ -286,6 +314,7 @@ export function defineSignin(options: {
286
314
  browserSessionHours?: number;
287
315
  cliSessionDays?: number;
288
316
  workloads?: WorkloadsOptions;
317
+ mcp?: McpOptions;
289
318
  }): SigninConfig {
290
319
  if (options.providers.length === 0) {
291
320
  throw new Error('sign-in needs at least one provider');
@@ -306,9 +335,27 @@ export function defineSignin(options: {
306
335
  browserSessionHours: lifetime(options.browserSessionHours, 'browserSessionHours', 12, 24 * 7),
307
336
  cliSessionDays: lifetime(options.cliSessionDays, 'cliSessionDays', 30, 365),
308
337
  workloads: options.workloads === undefined ? null : workloadsConfig(options.workloads, publicOrigin(options.publicUrl)),
338
+ mcp: options.mcp === undefined ? null : mcpConfig(options.mcp, publicOrigin(options.publicUrl)),
309
339
  };
310
340
  }
311
341
 
342
+ /** MCP, checked: its three limits given, each one a limiter; loopback clients only on a loopback instance. */
343
+ function mcpConfig(options: McpOptions, publicUrl: string): McpConfig {
344
+ const limiter = (value: unknown) => typeof (value as RateLimiter | undefined)?.limit === 'function';
345
+ if (!limiter(options.limits?.perSource) || !limiter(options.limits?.perConnection) || !limiter(options.limits?.total)) {
346
+ throw new Error(
347
+ 'mcp needs its limits, per source, per connection and in total: on Workers, three rate-limiting bindings; on Node, processLimits()',
348
+ );
349
+ }
350
+ const loopback = options.allowLoopbackClientsForDevelopment === true;
351
+ if (loopback && !isLoopback(new URL(publicUrl))) {
352
+ throw new Error(
353
+ `mcp.allowLoopbackClientsForDevelopment is for an instance on loopback, and ${publicUrl} is not one: turn it off`,
354
+ );
355
+ }
356
+ return { allowLoopback: loopback, limits: options.limits };
357
+ }
358
+
312
359
  /** Workloads, checked: both limits given, each one a limiter; loopback issuers only on a loopback instance. */
313
360
  function workloadsConfig(options: WorkloadsOptions, publicUrl: string): WorkloadsConfig {
314
361
  const limiter = (value: unknown) => typeof (value as RateLimiter | undefined)?.limit === 'function';