@coffre/core 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access.d.ts +59 -13
- package/dist/access.js +50 -11
- package/dist/identity.d.ts +35 -0
- package/dist/identity.js +15 -2
- package/dist/mcp.d.ts +96 -0
- package/dist/mcp.js +201 -0
- package/dist/pages.d.ts +7 -0
- package/dist/pages.js +12 -1
- package/dist/schemas.d.ts +20 -0
- package/dist/schemas.js +29 -1
- package/dist/vault.d.ts +83 -15
- package/dist/vault.js +6 -1
- package/package.json +7 -2
- package/src/access-fault.ts +9 -3
- package/src/access.ts +83 -19
- package/src/identity/auth.ts +4 -1
- package/src/identity/signin/config.ts +47 -0
- package/src/mcp.ts +233 -0
- package/src/pages.ts +10 -1
- package/src/schemas.ts +37 -0
- package/src/vault.ts +68 -13
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
|
-
/**
|
|
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?:
|
|
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
|
|
112
|
+
* Whether a grant at `grant` reaches `place`:
|
|
80
113
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
|
85
|
-
*
|
|
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
|
-
/**
|
|
89
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
80
|
-
*
|
|
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
|
|
83
|
-
*
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 };
|
package/dist/identity.d.ts
CHANGED
|
@@ -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,8 +1,28 @@
|
|
|
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";
|