@coffre/core 0.3.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 +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/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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
/**
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 = (
|
|
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.
|
|
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.
|
|
87
|
+
"@coffre/conformance": "0.4.0"
|
|
83
88
|
},
|
|
84
89
|
"scripts": {
|
|
85
90
|
"build": "tsdown",
|
package/src/access-fault.ts
CHANGED
|
@@ -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;
|
|
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 = (
|
|
42
|
-
|
|
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
|
-
/**
|
|
89
|
-
|
|
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
|
|
146
|
+
grants: readonly (GrantPlace & { role: Role })[];
|
|
104
147
|
};
|
|
105
148
|
|
|
106
149
|
/**
|
|
107
|
-
* Whether
|
|
150
|
+
* Whether a grant at `grant` reaches `place`:
|
|
108
151
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
113
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
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
|
}
|
package/src/identity/auth.ts
CHANGED
|
@@ -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';
|