@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/access.d.ts CHANGED
@@ -52,11 +52,46 @@ export declare function isRole(value: string): value is Role;
52
52
  export declare function roleGrants(role: Role, permission: Permission): boolean;
53
53
  /** Roles that hold a project-only permission can only be granted on a project. */
54
54
  export declare function assignableToEnvironment(role: Role): boolean;
55
- /** A place a permission applies to: a project, or one of its environments. */
55
+ /**
56
+ * A place a permission applies to: a project, or one of its environments,
57
+ * named by its slug too, which grants on every project match by. A slug
58
+ * not read (null) matches no grant on a slug.
59
+ */
56
60
  export type Place = {
57
61
  projectId: string;
58
- environmentId?: string | null;
62
+ environmentId?: null;
63
+ } | {
64
+ projectId: string;
65
+ environmentId: string;
66
+ environmentSlug: string | null;
67
+ };
68
+ /**
69
+ * Where a grant applies, by the fields it fills (`grantKind`):
70
+ *
71
+ * market projectId
72
+ * market/dev projectId and environmentId
73
+ * * neither: every project, the ones created later too
74
+ * *, on dev environmentSlug only: the environment of that slug in every project
75
+ */
76
+ export type GrantPlace = {
77
+ /** The project, also of an environment's grant; null only on every project. */
78
+ projectId: string | null;
79
+ environmentId: string | null;
80
+ /** On every project, the one environment slug it covers; null for all of them, and on a project's grant. */
81
+ environmentSlug: string | null;
59
82
  };
83
+ /**
84
+ * What a grant's fields say it is on, or null when they say nothing
85
+ * coherent: an environment without its project, as a grant whose
86
+ * environment row is gone reads, or a slug beside an id. A grant is on
87
+ * every project only when it names no place at all; anything else that
88
+ * lacks a field covers nothing.
89
+ */
90
+ export declare function grantKind(grant: GrantPlace): 'project' | 'environment' | 'every-project' | null;
91
+ /** Every project, as a path names it. */
92
+ export declare const EVERY_PROJECT = "*";
93
+ /** A grant on every project as a path: `*`, or `*` and the environment slug it covers in each. */
94
+ export declare function everyProjectPath(environmentSlug: string | null): string;
60
95
  /**
61
96
  * What someone holds: everything a permission check reads. The app builds
62
97
  * it from what the vault says once per request; the vault builds it from
@@ -69,22 +104,33 @@ export type Holdings = {
69
104
  /** Root admins and active users with the instance `owner` role. */
70
105
  isOwner: boolean;
71
106
  /** Live grants only. */
72
- grants: readonly {
73
- projectId: string;
74
- environmentId: string | null;
107
+ grants: readonly (GrantPlace & {
75
108
  role: Role;
76
- }[];
109
+ })[];
77
110
  };
78
111
  /**
79
- * Whether `holder` may do `permission` at `place`.
112
+ * Whether a grant at `grant` reaches `place`:
80
113
  *
81
- * on a project its project grant; owners also manage every project
82
- * on an environment its environment grant or its project's grant
114
+ * market market and each of its environments
115
+ * market/dev market/dev
116
+ * * every project and every environment
117
+ * *, on dev each environment whose slug is dev, in every project
83
118
  *
84
- * An environment grant never reaches up to the project: `developer` on
85
- * `market/prod` does not let anyone rename `market`.
119
+ * An environment's grant never reaches up to its project, whether it names
120
+ * the environment or its slug.
121
+ */
122
+ export declare function covers(grant: GrantPlace, place: Place): boolean;
123
+ /**
124
+ * Whether `holder` may do `permission` at `place`: a grant that covers it
125
+ * gives the role's permissions there, and grants add up. Owners also manage
126
+ * every project. `developer` on `market/prod` does not let anyone rename
127
+ * `market`.
86
128
  */
87
129
  export declare function allows(holder: Holdings, permission: Permission, place: Place): boolean;
88
- /** Whether the actor may manage grants on this project. */
89
- export declare function mayManageAccess(actor: Holdings, place: Place): boolean;
130
+ /**
131
+ * Whether the actor may manage grants at this place: on a project or its
132
+ * environments, with `grant.manage` on the project; on every project, as an
133
+ * instance owner or a root admin only.
134
+ */
135
+ export declare function mayManageAccess(actor: Holdings, place: GrantPlace): boolean;
90
136
  //#endregion
package/dist/access.js CHANGED
@@ -74,23 +74,62 @@ function assignableToEnvironment(role) {
74
74
  return !ROLES[role].permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission));
75
75
  }
76
76
  /**
77
- * Whether `holder` may do `permission` at `place`.
77
+ * What a grant's fields say it is on, or null when they say nothing
78
+ * coherent: an environment without its project, as a grant whose
79
+ * environment row is gone reads, or a slug beside an id. A grant is on
80
+ * every project only when it names no place at all; anything else that
81
+ * lacks a field covers nothing.
82
+ */
83
+ function grantKind(grant) {
84
+ if (grant.environmentId !== null) return grant.projectId !== null && grant.environmentSlug === null ? "environment" : null;
85
+ if (grant.projectId !== null) return grant.environmentSlug === null ? "project" : null;
86
+ return "every-project";
87
+ }
88
+ /** Every project, as a path names it. */
89
+ const EVERY_PROJECT = "*";
90
+ /** A grant on every project as a path: `*`, or `*` and the environment slug it covers in each. */
91
+ function everyProjectPath(environmentSlug) {
92
+ return environmentSlug === null ? "*" : `*/${environmentSlug}`;
93
+ }
94
+ /**
95
+ * Whether a grant at `grant` reaches `place`:
78
96
  *
79
- * on a project its project grant; owners also manage every project
80
- * on an environment its environment grant or its project's grant
97
+ * market market and each of its environments
98
+ * market/dev market/dev
99
+ * * every project and every environment
100
+ * *, on dev each environment whose slug is dev, in every project
81
101
  *
82
- * An environment grant never reaches up to the project: `developer` on
83
- * `market/prod` does not let anyone rename `market`.
102
+ * An environment's grant never reaches up to its project, whether it names
103
+ * the environment or its slug.
104
+ */
105
+ function covers(grant, place) {
106
+ switch (grantKind(grant)) {
107
+ case "environment": return grant.projectId === place.projectId && grant.environmentId === (place.environmentId ?? null);
108
+ case "project": return grant.projectId === place.projectId;
109
+ case "every-project": return grant.environmentSlug === null || place.environmentId != null && place.environmentSlug !== null && place.environmentSlug === grant.environmentSlug;
110
+ case null: return false;
111
+ }
112
+ }
113
+ /**
114
+ * Whether `holder` may do `permission` at `place`: a grant that covers it
115
+ * gives the role's permissions there, and grants add up. Owners also manage
116
+ * every project. `developer` on `market/prod` does not let anyone rename
117
+ * `market`.
84
118
  */
85
119
  function allows(holder, permission, place) {
86
120
  if (holder.isRootAdmin) return true;
87
- const environmentId = place.environmentId ?? null;
88
- if (environmentId === null && holder.isOwner && PROJECT_ONLY_PERMISSIONS.includes(permission)) return true;
89
- return holder.grants.some((grant) => grant.projectId === place.projectId && (grant.environmentId === null || grant.environmentId === environmentId) && roleGrants(grant.role, permission));
121
+ if (place.environmentId == null && holder.isOwner && PROJECT_ONLY_PERMISSIONS.includes(permission)) return true;
122
+ return holder.grants.some((grant) => covers(grant, place) && roleGrants(grant.role, permission));
90
123
  }
91
- /** Whether the actor may manage grants on this project. */
124
+ /**
125
+ * Whether the actor may manage grants at this place: on a project or its
126
+ * environments, with `grant.manage` on the project; on every project, as an
127
+ * instance owner or a root admin only.
128
+ */
92
129
  function mayManageAccess(actor, place) {
93
- return allows(actor, "grant.manage", { projectId: place.projectId });
130
+ const kind = grantKind(place);
131
+ if (kind === "every-project") return actor.isOwner || actor.isRootAdmin;
132
+ return kind !== null && place.projectId !== null && allows(actor, "grant.manage", { projectId: place.projectId });
94
133
  }
95
134
  //#endregion
96
- export { PERMISSIONS, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_NAMES, allows, assignableToEnvironment, isRole, mayManageAccess, roleGrants };
135
+ export { EVERY_PROJECT, PERMISSIONS, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_NAMES, allows, assignableToEnvironment, covers, everyProjectPath, grantKind, isRole, mayManageAccess, roleGrants };
@@ -192,6 +192,8 @@ export type SigninConfig = {
192
192
  cliSessionDays: number;
193
193
  /** CI runs signing in as services with their platform's ID token, when on (`workloads.ts`). */
194
194
  workloads: WorkloadsConfig | null;
195
+ /** MCP clients acting as the people who connect them, when on (docs/design/mcp.md). */
196
+ mcp: McpConfig | null;
195
197
  };
196
198
  /**
197
199
  * Workload identity: CI runs that sign in as a service with the ID token
@@ -231,6 +233,36 @@ export type WorkloadsConfig = {
231
233
  allowLoopback: boolean;
232
234
  limits: WorkloadLimits;
233
235
  };
236
+ /**
237
+ * MCP clients, Claude among them, acting as the people who connect them,
238
+ * with coffre as their OAuth authorization server (docs/design/mcp.md). Off
239
+ * unless the deployment turns it on.
240
+ */
241
+ export type McpOptions = {
242
+ /**
243
+ * The limits every unauthenticated OAuth request passes before coffre does
244
+ * any work for it, one per source address and one for all together, and
245
+ * the one each connection's tool calls pass. On Workers, three
246
+ * rate-limiting bindings; on Node, `processLimits()` from
247
+ * `@coffre/server/node`. Required.
248
+ */
249
+ limits: McpLimits;
250
+ /**
251
+ * Lets a client's metadata document be plain HTTP on loopback: for local
252
+ * development and conformance. Refused unless the public URL is loopback
253
+ * too.
254
+ */
255
+ allowLoopbackClientsForDevelopment?: boolean;
256
+ };
257
+ export type McpLimits = {
258
+ perSource: RateLimiter;
259
+ perConnection: RateLimiter;
260
+ total: RateLimiter;
261
+ };
262
+ export type McpConfig = {
263
+ allowLoopback: boolean;
264
+ limits: McpLimits;
265
+ };
234
266
  type Credentials = {
235
267
  clientId: string;
236
268
  clientSecret: string;
@@ -291,6 +323,7 @@ export declare function defineSignin(options: {
291
323
  browserSessionHours?: number;
292
324
  cliSessionDays?: number;
293
325
  workloads?: WorkloadsOptions;
326
+ mcp?: McpOptions;
294
327
  }): SigninConfig;
295
328
  //#endregion
296
329
  //#region src/identity/types.d.ts
@@ -394,6 +427,8 @@ export type SigninOptions = {
394
427
  cliSessionDays?: number;
395
428
  /** Lets CI runs sign in as services with their platform's ID token: `{}` turns it on. */
396
429
  workloads?: WorkloadsOptions;
430
+ /** Lets MCP clients, Claude among them, act as the people who connect them (docs/mcp.md). */
431
+ mcp?: McpOptions;
397
432
  };
398
433
  /**
399
434
  * coffre's own sign-in page, with these providers:
package/dist/identity.js CHANGED
@@ -452,7 +452,19 @@ function defineSignin(options) {
452
452
  },
453
453
  browserSessionHours: lifetime(options.browserSessionHours, "browserSessionHours", 12, 168),
454
454
  cliSessionDays: lifetime(options.cliSessionDays, "cliSessionDays", 30, 365),
455
- workloads: options.workloads === void 0 ? null : workloadsConfig(options.workloads, publicOrigin(options.publicUrl))
455
+ workloads: options.workloads === void 0 ? null : workloadsConfig(options.workloads, publicOrigin(options.publicUrl)),
456
+ mcp: options.mcp === void 0 ? null : mcpConfig(options.mcp, publicOrigin(options.publicUrl))
457
+ };
458
+ }
459
+ /** MCP, checked: its three limits given, each one a limiter; loopback clients only on a loopback instance. */
460
+ function mcpConfig(options, publicUrl) {
461
+ const limiter = (value) => typeof value?.limit === "function";
462
+ if (!limiter(options.limits?.perSource) || !limiter(options.limits?.perConnection) || !limiter(options.limits?.total)) throw new Error("mcp needs its limits, per source, per connection and in total: on Workers, three rate-limiting bindings; on Node, processLimits()");
463
+ const loopback = options.allowLoopbackClientsForDevelopment === true;
464
+ if (loopback && !isLoopback(new URL(publicUrl))) throw new Error(`mcp.allowLoopbackClientsForDevelopment is for an instance on loopback, and ${publicUrl} is not one: turn it off`);
465
+ return {
466
+ allowLoopback: loopback,
467
+ limits: options.limits
456
468
  };
457
469
  }
458
470
  /** Workloads, checked: both limits given, each one a limiter; loopback issuers only on a loopback instance. */
@@ -544,7 +556,8 @@ function toSignin(options) {
544
556
  },
545
557
  browserSessionHours: options.browserSessionHours,
546
558
  cliSessionDays: options.cliSessionDays,
547
- workloads: options.workloads
559
+ workloads: options.workloads,
560
+ mcp: options.mcp
548
561
  };
549
562
  }
550
563
  //#endregion
package/dist/mcp.d.ts ADDED
@@ -0,0 +1,96 @@
1
+ //#region src/mcp.d.ts
2
+ /**
3
+ * What MCP clients may be, and do: the scopes a person grants one, and the
4
+ * rules its identity and redirects must pass (docs/design/mcp.md, sections
5
+ * 4 and 5). Plain functions over strings, for the server and the consent
6
+ * page alike.
7
+ */
8
+ /** In the order the consent page lists them; `browse` is always granted. */
9
+ export declare const MCP_SCOPES: readonly ["browse", "write", "read-values", "manage-access"];
10
+ export type McpScope = (typeof MCP_SCOPES)[number];
11
+ export declare const MCP_SCOPE_INFO: Record<McpScope, {
12
+ label: string;
13
+ description: string;
14
+ }>;
15
+ export declare function isMcpScope(value: string): value is McpScope;
16
+ /**
17
+ * The scopes a space-separated `scope` asks for, in catalogue order, with
18
+ * `browse` always among them; and the ones coffre does not know, which the
19
+ * caller refuses as `invalid_scope`.
20
+ */
21
+ export declare function parseScopes(value: string | null | undefined): {
22
+ scopes: McpScope[];
23
+ unknown: string[];
24
+ };
25
+ /** Scopes as one string, in catalogue order: how a connection stores them and a token answer says them. */
26
+ export declare function scopeString(scopes: Iterable<McpScope>): string;
27
+ /** A person's choice at consent: what was asked, minus what they unticked, and never without `browse`. */
28
+ export declare function grantedScopes(asked: readonly McpScope[], chosen: readonly string[]): McpScope[];
29
+ /**
30
+ * Whether a connection granted `granted` supersedes an earlier one of the
31
+ * same client that holds `held`: it grants all of that and more, as a
32
+ * step-up does. The earlier one then ends, once the new one's code is
33
+ * redeemed; one with the same scopes, a second laptop say, stays.
34
+ */
35
+ export declare function supersedes(granted: readonly McpScope[], held: readonly McpScope[]): boolean;
36
+ /** A client's redirect, refused: the message says why, for the consent page and the registration answer. */
37
+ export declare class ClientInvalid extends Error {
38
+ constructor(message: string);
39
+ }
40
+ export type RedirectKind = 'https' | 'loopback';
41
+ /**
42
+ * Where a redirect may go: HTTPS anywhere, or plain HTTP on loopback, which
43
+ * a native client such as Claude Code listens on (RFC 8252). No custom
44
+ * scheme, no fragment, no user or password.
45
+ */
46
+ export declare function redirectKind(value: string): RedirectKind;
47
+ /**
48
+ * Whether a requested redirect is one a client registered. Both are parsed
49
+ * first, so `http://127.0.0.1:33418` and `http://127.0.0.1:33418/` are one
50
+ * URL. A loopback one matches on any port, as RFC 8252 lets a native client
51
+ * bind whichever it can; everything else exactly.
52
+ */
53
+ export declare function redirectMatches(registered: string, requested: string): boolean;
54
+ /** The redirect's host, as the consent page shows it: `localhost` for every loopback one. */
55
+ export declare function redirectHost(value: string): string;
56
+ /** What coffre knows of a client, from its metadata document or its registration. */
57
+ export type ClientMetadata = {
58
+ /** The document's URL, or the ID a registration was given. */
59
+ clientId: string;
60
+ /** Its own claim: shown, never trusted. */
61
+ name: string;
62
+ /** For a document, its URL's host; for a registration, null: it has none to vouch for it. */
63
+ host: string | null;
64
+ redirectUris: string[];
65
+ registration: 'cimd' | 'dcr';
66
+ };
67
+ /**
68
+ * Whether `value` is a Client ID Metadata Document's URL: HTTPS, with a
69
+ * path, and nothing a document's address has no use for. On a loopback
70
+ * instance, a loopback one too, for development and conformance.
71
+ */
72
+ export declare function isClientIdUrl(value: string, options: {
73
+ allowLoopback: boolean;
74
+ }): boolean;
75
+ /**
76
+ * A fetched Client ID Metadata Document, checked: it names itself, a name
77
+ * and its redirects, and it is a public client. An HTTPS redirect must be on
78
+ * the document's own host, so the consent page names one host: Claude's
79
+ * `https://claude.ai/api/mcp/auth_callback` is on `claude.ai`, where its
80
+ * document is.
81
+ */
82
+ export declare function clientFromDocument(clientId: string, document: unknown): ClientMetadata;
83
+ /**
84
+ * What a registration may keep of what it asked for (RFC 7591): the
85
+ * redirects coffre accepts, the others left out rather than failing it, as
86
+ * the RFC lets a server replace what was asked. Cursor registers a
87
+ * `cursor://` callback beside its loopback and HTTPS ones; refused whole,
88
+ * it could not connect at all.
89
+ */
90
+ export declare function registrationRedirects(value: unknown): {
91
+ kept: string[];
92
+ dropped: string[];
93
+ };
94
+ /** A client's name as shown: its own, cut short, or its host when it gives none. */
95
+ export declare function clientName(value: unknown, fallback: string): string;
96
+ //#endregion
package/dist/mcp.js ADDED
@@ -0,0 +1,201 @@
1
+ //#region src/mcp.ts
2
+ /**
3
+ * What MCP clients may be, and do: the scopes a person grants one, and the
4
+ * rules its identity and redirects must pass (docs/design/mcp.md, sections
5
+ * 4 and 5). Plain functions over strings, for the server and the consent
6
+ * page alike.
7
+ */
8
+ /** In the order the consent page lists them; `browse` is always granted. */
9
+ const MCP_SCOPES = [
10
+ "browse",
11
+ "write",
12
+ "read-values",
13
+ "manage-access"
14
+ ];
15
+ const MCP_SCOPE_INFO = {
16
+ browse: {
17
+ label: "Browse",
18
+ description: "Projects, environments, key names, history, access and the audit log. Never a value."
19
+ },
20
+ write: {
21
+ label: "Write",
22
+ description: "Set, generate, rename, archive and restore secrets; create projects and environments. Each change waits for your approval on coffre."
23
+ },
24
+ "read-values": {
25
+ label: "Read values",
26
+ description: "Secret values, sent to the app. They become part of its conversation, wherever it keeps it."
27
+ },
28
+ "manage-access": {
29
+ label: "Manage access",
30
+ description: "Grants, members, service tokens and trusted workloads. Each change waits for your approval on coffre."
31
+ }
32
+ };
33
+ /** What OAuth's `offline_access` asks for, a refresh token, every connection has: accepted and dropped. */
34
+ const IGNORED_SCOPES = /* @__PURE__ */ new Set(["offline_access"]);
35
+ function isMcpScope(value) {
36
+ return MCP_SCOPES.includes(value);
37
+ }
38
+ /**
39
+ * The scopes a space-separated `scope` asks for, in catalogue order, with
40
+ * `browse` always among them; and the ones coffre does not know, which the
41
+ * caller refuses as `invalid_scope`.
42
+ */
43
+ function parseScopes(value) {
44
+ const asked = new Set((value ?? "").split(" ").filter((scope) => scope !== "" && !IGNORED_SCOPES.has(scope)));
45
+ const unknown = [...asked].filter((scope) => !isMcpScope(scope));
46
+ return {
47
+ scopes: MCP_SCOPES.filter((scope) => scope === "browse" || asked.has(scope)),
48
+ unknown
49
+ };
50
+ }
51
+ /** Scopes as one string, in catalogue order: how a connection stores them and a token answer says them. */
52
+ function scopeString(scopes) {
53
+ const held = new Set(scopes);
54
+ return MCP_SCOPES.filter((scope) => held.has(scope)).join(" ");
55
+ }
56
+ /** A person's choice at consent: what was asked, minus what they unticked, and never without `browse`. */
57
+ function grantedScopes(asked, chosen) {
58
+ const picked = new Set(chosen);
59
+ return MCP_SCOPES.filter((scope) => scope === "browse" || asked.includes(scope) && picked.has(scope));
60
+ }
61
+ /**
62
+ * Whether a connection granted `granted` supersedes an earlier one of the
63
+ * same client that holds `held`: it grants all of that and more, as a
64
+ * step-up does. The earlier one then ends, once the new one's code is
65
+ * redeemed; one with the same scopes, a second laptop say, stays.
66
+ */
67
+ function supersedes(granted, held) {
68
+ return held.every((scope) => granted.includes(scope)) && granted.some((scope) => !held.includes(scope));
69
+ }
70
+ /** A client's redirect, refused: the message says why, for the consent page and the registration answer. */
71
+ var ClientInvalid = class extends Error {
72
+ constructor(message) {
73
+ super(message);
74
+ this.name = "ClientInvalid";
75
+ }
76
+ };
77
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
78
+ "127.0.0.1",
79
+ "[::1]",
80
+ "localhost"
81
+ ]);
82
+ /**
83
+ * Where a redirect may go: HTTPS anywhere, or plain HTTP on loopback, which
84
+ * a native client such as Claude Code listens on (RFC 8252). No custom
85
+ * scheme, no fragment, no user or password.
86
+ */
87
+ function redirectKind(value) {
88
+ let url;
89
+ try {
90
+ url = new URL(value);
91
+ } catch {
92
+ throw new ClientInvalid(`${value} is not a URL`);
93
+ }
94
+ if (url.username !== "" || url.password !== "") throw new ClientInvalid(`${value} carries a user or password`);
95
+ if (url.hash !== "" || value.includes("#")) throw new ClientInvalid(`${value} has a fragment`);
96
+ if (url.protocol === "https:") return "https";
97
+ if (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname)) return "loopback";
98
+ throw new ClientInvalid(`${value} is neither HTTPS nor HTTP on loopback (127.0.0.1, [::1] or localhost)`);
99
+ }
100
+ /**
101
+ * Whether a requested redirect is one a client registered. Both are parsed
102
+ * first, so `http://127.0.0.1:33418` and `http://127.0.0.1:33418/` are one
103
+ * URL. A loopback one matches on any port, as RFC 8252 lets a native client
104
+ * bind whichever it can; everything else exactly.
105
+ */
106
+ function redirectMatches(registered, requested) {
107
+ let a;
108
+ let b;
109
+ try {
110
+ redirectKind(requested);
111
+ a = new URL(registered);
112
+ b = new URL(requested);
113
+ } catch {
114
+ return false;
115
+ }
116
+ if (a.protocol === "http:" && LOOPBACK_HOSTS.has(a.hostname)) return b.protocol === "http:" && a.hostname === b.hostname && a.pathname === b.pathname && a.search === b.search;
117
+ return a.href === b.href;
118
+ }
119
+ /** The redirect's host, as the consent page shows it: `localhost` for every loopback one. */
120
+ function redirectHost(value) {
121
+ const url = new URL(value);
122
+ return redirectKind(value) === "loopback" ? "localhost" : url.host;
123
+ }
124
+ const MAX_REDIRECTS = 10;
125
+ const MAX_NAME = 100;
126
+ /**
127
+ * Whether `value` is a Client ID Metadata Document's URL: HTTPS, with a
128
+ * path, and nothing a document's address has no use for. On a loopback
129
+ * instance, a loopback one too, for development and conformance.
130
+ */
131
+ function isClientIdUrl(value, options) {
132
+ if (!value.startsWith("https://") && !(options.allowLoopback && value.startsWith("http://"))) return false;
133
+ let url;
134
+ try {
135
+ url = new URL(value);
136
+ } catch {
137
+ return false;
138
+ }
139
+ if (url.href !== value || url.pathname === "/" || url.search !== "" || url.hash !== "") return false;
140
+ if (url.username !== "" || url.password !== "" || /\/\.\.?(\/|$)/.test(url.pathname)) return false;
141
+ return url.protocol === "https:" || LOOPBACK_HOSTS.has(url.hostname);
142
+ }
143
+ /**
144
+ * A fetched Client ID Metadata Document, checked: it names itself, a name
145
+ * and its redirects, and it is a public client. An HTTPS redirect must be on
146
+ * the document's own host, so the consent page names one host: Claude's
147
+ * `https://claude.ai/api/mcp/auth_callback` is on `claude.ai`, where its
148
+ * document is.
149
+ */
150
+ function clientFromDocument(clientId, document) {
151
+ if (typeof document !== "object" || document === null || Array.isArray(document)) throw new ClientInvalid("the client metadata document is not a JSON object");
152
+ const fields = document;
153
+ if (fields.client_id !== clientId) throw new ClientInvalid("the client metadata document names another client_id than its own URL");
154
+ const method = fields.token_endpoint_auth_method;
155
+ if (method !== void 0 && method !== "none") throw new ClientInvalid(`the client authenticates with ${String(method)}; coffre takes public clients only, with PKCE`);
156
+ const host = new URL(clientId).host;
157
+ const uris = redirectList(fields.redirect_uris);
158
+ for (const uri of uris) if (redirectKind(uri) === "https" && new URL(uri).host !== host) throw new ClientInvalid(`${uri} is not on ${host}, where the client's metadata document is`);
159
+ return {
160
+ clientId,
161
+ name: clientName(fields.client_name, host),
162
+ host,
163
+ redirectUris: uris,
164
+ registration: "cimd"
165
+ };
166
+ }
167
+ /**
168
+ * What a registration may keep of what it asked for (RFC 7591): the
169
+ * redirects coffre accepts, the others left out rather than failing it, as
170
+ * the RFC lets a server replace what was asked. Cursor registers a
171
+ * `cursor://` callback beside its loopback and HTTPS ones; refused whole,
172
+ * it could not connect at all.
173
+ */
174
+ function registrationRedirects(value) {
175
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_REDIRECTS || value.some((uri) => typeof uri !== "string" || uri.length > 400)) throw new ClientInvalid(`redirect_uris must list 1 to ${MAX_REDIRECTS} URLs`);
176
+ const kept = [];
177
+ const dropped = [];
178
+ for (const uri of value) try {
179
+ redirectKind(uri);
180
+ if (!kept.includes(uri)) kept.push(uri);
181
+ } catch {
182
+ dropped.push(uri);
183
+ }
184
+ if (kept.length === 0) throw new ClientInvalid(`none of the redirect_uris is HTTPS or HTTP on loopback: ${dropped.join(", ")}`);
185
+ return {
186
+ kept,
187
+ dropped
188
+ };
189
+ }
190
+ /** A client's name as shown: its own, cut short, or its host when it gives none. */
191
+ function clientName(value, fallback) {
192
+ const name = typeof value === "string" ? value.replace(/[\p{Cc}\p{Cf}]/gu, "").trim() : "";
193
+ return name === "" ? fallback : name.slice(0, MAX_NAME);
194
+ }
195
+ function redirectList(value) {
196
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_REDIRECTS || value.some((uri) => typeof uri !== "string")) throw new ClientInvalid(`the client metadata document must list 1 to ${MAX_REDIRECTS} redirect_uris`);
197
+ for (const uri of value) redirectKind(uri);
198
+ return value;
199
+ }
200
+ //#endregion
201
+ export { ClientInvalid, MCP_SCOPES, MCP_SCOPE_INFO, clientFromDocument, clientName, grantedScopes, isClientIdUrl, isMcpScope, parseScopes, redirectHost, redirectKind, redirectMatches, registrationRedirects, scopeString, supersedes };
package/dist/pages.d.ts CHANGED
@@ -12,6 +12,13 @@ export declare const PREFERENCE_COOKIES: {
12
12
  readonly theme: "coffre-theme";
13
13
  readonly sidebar: "coffre-sidebar";
14
14
  };
15
+ /**
16
+ * A project's pages beside its environments': `/projects/market/settings`
17
+ * sits where `/projects/market/prod` does, so no environment takes one of
18
+ * these names (`environmentSlug` in `./schemas.ts`). A tab a project gains
19
+ * adds its word here.
20
+ */
21
+ export declare const PROJECT_PAGES: readonly ["users", "service-accounts", "settings"];
15
22
  /** The preferences in a `Cookie` header, or in `document.cookie`, which reads the same. */
16
23
  export declare function preferencesIn(cookies: string | null): Preferences;
17
24
  /** What a server route or a page says, rendered without coffre's request middleware. */
package/dist/pages.js CHANGED
@@ -4,6 +4,17 @@ const PREFERENCE_COOKIES = {
4
4
  theme: "coffre-theme",
5
5
  sidebar: "coffre-sidebar"
6
6
  };
7
+ /**
8
+ * A project's pages beside its environments': `/projects/market/settings`
9
+ * sits where `/projects/market/prod` does, so no environment takes one of
10
+ * these names (`environmentSlug` in `./schemas.ts`). A tab a project gains
11
+ * adds its word here.
12
+ */
13
+ const PROJECT_PAGES = [
14
+ "users",
15
+ "service-accounts",
16
+ "settings"
17
+ ];
7
18
  /** The preferences in a `Cookie` header, or in `document.cookie`, which reads the same. */
8
19
  function preferencesIn(cookies) {
9
20
  const value = (name) => {
@@ -21,4 +32,4 @@ function preferencesIn(cookies) {
21
32
  /** What a server route or a page says, rendered without coffre's request middleware. */
22
33
  const NO_MIDDLEWARE = "coffre's request middleware is not installed: add coffreMiddleware, from @coffre/server/start, to createStart(() => ({ requestMiddleware: [coffreMiddleware] })) in the app's src/start.ts";
23
34
  //#endregion
24
- export { NO_MIDDLEWARE, PREFERENCE_COOKIES, preferencesIn };
35
+ export { NO_MIDDLEWARE, PREFERENCE_COOKIES, PROJECT_PAGES, preferencesIn };
package/dist/schemas.d.ts CHANGED
@@ -1,16 +1,36 @@
1
1
  import { z } from "zod";
2
2
  //#region src/schemas.d.ts
3
3
  export declare const slug: z.ZodString;
4
+ /** An environment's slug: any slug but the name of one of its project's pages. */
5
+ export declare const environmentSlug: z.ZodString;
6
+ /**
7
+ * A deleted project or environment keeps its row as a tombstone, under a
8
+ * slug no live place can hold: `market~deleted-2026-10-05`. The `~` is the
9
+ * whole rule: a place is deleted when its slug, or its project's, holds one.
10
+ * `@coffre/db/dialect`'s `tombstone()` is the same rule in SQL.
11
+ */
12
+ export declare const TOMBSTONE = "~";
13
+ export declare function isTombstone(slug: string): boolean;
14
+ /** The slug a place deleted on `day` keeps: the `n`th that day is `market~deleted-2026-10-05-<n>`. */
15
+ export declare function tombstoneOf(slug: string, day: Date, n?: number): string;
4
16
  export declare const displayName: z.ZodString;
17
+ /** A folder: 1 to 64 characters, no `/`, no control character, no space at either end (the tables check it too). */
18
+ export declare const folderName: z.ZodString;
5
19
  export declare const secretKey: z.ZodString;
20
+ /**
21
+ * A member as people read it, in a message or on a page: a service account
22
+ * as `service:<name>`, though the API, the vault and the log keep the
23
+ * `token:<name>` their signed entries hold.
24
+ */
25
+ export declare function shownMember(member: string): string;
6
26
  export declare const principalType: z.ZodEnum<{
7
27
  user: "user";
8
28
  service: "service";
9
29
  }>;
10
30
  export declare const principalId: z.ZodString;
11
31
  export declare const instanceRole: z.ZodEnum<{
12
- owner: "owner";
13
32
  user: "user";
33
+ owner: "owner";
14
34
  }>;
15
35
  export declare const grantId: z.ZodString;
16
36
  export declare const emailAddress: z.ZodString;