@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/src/mcp.ts ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * What MCP clients may be, and do: the scopes a person grants one, and the
3
+ * rules its identity and redirects must pass (docs/design/mcp.md, sections
4
+ * 4 and 5). Plain functions over strings, for the server and the consent
5
+ * page alike.
6
+ */
7
+
8
+ /** In the order the consent page lists them; `browse` is always granted. */
9
+ export const MCP_SCOPES = ['browse', 'write', 'read-values', 'manage-access'] as const;
10
+
11
+ export type McpScope = (typeof MCP_SCOPES)[number];
12
+
13
+ export const MCP_SCOPE_INFO: Record<McpScope, { label: string; description: string }> = {
14
+ browse: {
15
+ label: 'Browse',
16
+ description: 'Projects, environments, key names, history, access and the audit log. Never a value.',
17
+ },
18
+ write: {
19
+ label: 'Write',
20
+ description: 'Set, generate, rename, archive and restore secrets; create projects and environments. Each change waits for your approval on coffre.',
21
+ },
22
+ 'read-values': {
23
+ label: 'Read values',
24
+ description: 'Secret values, sent to the app. They become part of its conversation, wherever it keeps it.',
25
+ },
26
+ 'manage-access': {
27
+ label: 'Manage access',
28
+ description: 'Grants, members, service tokens and trusted workloads. Each change waits for your approval on coffre.',
29
+ },
30
+ };
31
+
32
+ /** What OAuth's `offline_access` asks for, a refresh token, every connection has: accepted and dropped. */
33
+ const IGNORED_SCOPES = new Set(['offline_access']);
34
+
35
+ export function isMcpScope(value: string): value is McpScope {
36
+ return (MCP_SCOPES as readonly string[]).includes(value);
37
+ }
38
+
39
+ /**
40
+ * The scopes a space-separated `scope` asks for, in catalogue order, with
41
+ * `browse` always among them; and the ones coffre does not know, which the
42
+ * caller refuses as `invalid_scope`.
43
+ */
44
+ export function parseScopes(value: string | null | undefined): { scopes: McpScope[]; unknown: string[] } {
45
+ const asked = new Set((value ?? '').split(' ').filter((scope) => scope !== '' && !IGNORED_SCOPES.has(scope)));
46
+ const unknown = [...asked].filter((scope) => !isMcpScope(scope));
47
+ return { scopes: MCP_SCOPES.filter((scope) => scope === 'browse' || asked.has(scope)), unknown };
48
+ }
49
+
50
+ /** Scopes as one string, in catalogue order: how a connection stores them and a token answer says them. */
51
+ export function scopeString(scopes: Iterable<McpScope>): string {
52
+ const held = new Set(scopes);
53
+ return MCP_SCOPES.filter((scope) => held.has(scope)).join(' ');
54
+ }
55
+
56
+ /** A person's choice at consent: what was asked, minus what they unticked, and never without `browse`. */
57
+ export function grantedScopes(asked: readonly McpScope[], chosen: readonly string[]): McpScope[] {
58
+ const picked = new Set(chosen);
59
+ return MCP_SCOPES.filter((scope) => scope === 'browse' || (asked.includes(scope) && picked.has(scope)));
60
+ }
61
+
62
+ /**
63
+ * Whether a connection granted `granted` supersedes an earlier one of the
64
+ * same client that holds `held`: it grants all of that and more, as a
65
+ * step-up does. The earlier one then ends, once the new one's code is
66
+ * redeemed; one with the same scopes, a second laptop say, stays.
67
+ */
68
+ export function supersedes(granted: readonly McpScope[], held: readonly McpScope[]): boolean {
69
+ return held.every((scope) => granted.includes(scope)) && granted.some((scope) => !held.includes(scope));
70
+ }
71
+
72
+ // --- redirect URIs ----------------------------------------------------------
73
+
74
+ /** A client's redirect, refused: the message says why, for the consent page and the registration answer. */
75
+ export class ClientInvalid extends Error {
76
+ constructor(message: string) {
77
+ super(message);
78
+ this.name = 'ClientInvalid';
79
+ }
80
+ }
81
+
82
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '[::1]', 'localhost']);
83
+
84
+ export type RedirectKind = 'https' | 'loopback';
85
+
86
+ /**
87
+ * Where a redirect may go: HTTPS anywhere, or plain HTTP on loopback, which
88
+ * a native client such as Claude Code listens on (RFC 8252). No custom
89
+ * scheme, no fragment, no user or password.
90
+ */
91
+ export function redirectKind(value: string): RedirectKind {
92
+ let url: URL;
93
+ try {
94
+ url = new URL(value);
95
+ } catch {
96
+ throw new ClientInvalid(`${value} is not a URL`);
97
+ }
98
+ if (url.username !== '' || url.password !== '') throw new ClientInvalid(`${value} carries a user or password`);
99
+ if (url.hash !== '' || value.includes('#')) throw new ClientInvalid(`${value} has a fragment`);
100
+ if (url.protocol === 'https:') return 'https';
101
+ if (url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname)) return 'loopback';
102
+ throw new ClientInvalid(`${value} is neither HTTPS nor HTTP on loopback (127.0.0.1, [::1] or localhost)`);
103
+ }
104
+
105
+ /**
106
+ * Whether a requested redirect is one a client registered. Both are parsed
107
+ * first, so `http://127.0.0.1:33418` and `http://127.0.0.1:33418/` are one
108
+ * URL. A loopback one matches on any port, as RFC 8252 lets a native client
109
+ * bind whichever it can; everything else exactly.
110
+ */
111
+ export function redirectMatches(registered: string, requested: string): boolean {
112
+ let a: URL;
113
+ let b: URL;
114
+ try {
115
+ redirectKind(requested);
116
+ a = new URL(registered);
117
+ b = new URL(requested);
118
+ } catch {
119
+ return false;
120
+ }
121
+ if (a.protocol === 'http:' && LOOPBACK_HOSTS.has(a.hostname)) {
122
+ return b.protocol === 'http:' && a.hostname === b.hostname && a.pathname === b.pathname && a.search === b.search;
123
+ }
124
+ return a.href === b.href;
125
+ }
126
+
127
+ /** The redirect's host, as the consent page shows it: `localhost` for every loopback one. */
128
+ export function redirectHost(value: string): string {
129
+ const url = new URL(value);
130
+ return redirectKind(value) === 'loopback' ? 'localhost' : url.host;
131
+ }
132
+
133
+ // --- clients ------------------------------------------------------------------
134
+
135
+ /** What coffre knows of a client, from its metadata document or its registration. */
136
+ export type ClientMetadata = {
137
+ /** The document's URL, or the ID a registration was given. */
138
+ clientId: string;
139
+ /** Its own claim: shown, never trusted. */
140
+ name: string;
141
+ /** For a document, its URL's host; for a registration, null: it has none to vouch for it. */
142
+ host: string | null;
143
+ redirectUris: string[];
144
+ registration: 'cimd' | 'dcr';
145
+ };
146
+
147
+ const MAX_REDIRECTS = 10;
148
+ const MAX_NAME = 100;
149
+
150
+ /**
151
+ * Whether `value` is a Client ID Metadata Document's URL: HTTPS, with a
152
+ * path, and nothing a document's address has no use for. On a loopback
153
+ * instance, a loopback one too, for development and conformance.
154
+ */
155
+ export function isClientIdUrl(value: string, options: { allowLoopback: boolean }): boolean {
156
+ if (!value.startsWith('https://') && !(options.allowLoopback && value.startsWith('http://'))) return false;
157
+ let url: URL;
158
+ try {
159
+ url = new URL(value);
160
+ } catch {
161
+ return false;
162
+ }
163
+ if (url.href !== value || url.pathname === '/' || url.search !== '' || url.hash !== '') return false;
164
+ if (url.username !== '' || url.password !== '' || /\/\.\.?(\/|$)/.test(url.pathname)) return false;
165
+ return url.protocol === 'https:' || LOOPBACK_HOSTS.has(url.hostname);
166
+ }
167
+
168
+ /**
169
+ * A fetched Client ID Metadata Document, checked: it names itself, a name
170
+ * and its redirects, and it is a public client. An HTTPS redirect must be on
171
+ * the document's own host, so the consent page names one host: Claude's
172
+ * `https://claude.ai/api/mcp/auth_callback` is on `claude.ai`, where its
173
+ * document is.
174
+ */
175
+ export function clientFromDocument(clientId: string, document: unknown): ClientMetadata {
176
+ if (typeof document !== 'object' || document === null || Array.isArray(document)) {
177
+ throw new ClientInvalid('the client metadata document is not a JSON object');
178
+ }
179
+ const fields = document as Record<string, unknown>;
180
+ if (fields.client_id !== clientId) throw new ClientInvalid('the client metadata document names another client_id than its own URL');
181
+ const method = fields.token_endpoint_auth_method;
182
+ if (method !== undefined && method !== 'none') {
183
+ throw new ClientInvalid(`the client authenticates with ${String(method)}; coffre takes public clients only, with PKCE`);
184
+ }
185
+ const host = new URL(clientId).host;
186
+ const uris = redirectList(fields.redirect_uris);
187
+ for (const uri of uris) {
188
+ if (redirectKind(uri) === 'https' && new URL(uri).host !== host) {
189
+ throw new ClientInvalid(`${uri} is not on ${host}, where the client's metadata document is`);
190
+ }
191
+ }
192
+ return { clientId, name: clientName(fields.client_name, host), host, redirectUris: uris, registration: 'cimd' };
193
+ }
194
+
195
+ /**
196
+ * What a registration may keep of what it asked for (RFC 7591): the
197
+ * redirects coffre accepts, the others left out rather than failing it, as
198
+ * the RFC lets a server replace what was asked. Cursor registers a
199
+ * `cursor://` callback beside its loopback and HTTPS ones; refused whole,
200
+ * it could not connect at all.
201
+ */
202
+ export function registrationRedirects(value: unknown): { kept: string[]; dropped: string[] } {
203
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_REDIRECTS || value.some((uri) => typeof uri !== 'string' || uri.length > 400)) {
204
+ throw new ClientInvalid(`redirect_uris must list 1 to ${MAX_REDIRECTS} URLs`);
205
+ }
206
+ const kept: string[] = [];
207
+ const dropped: string[] = [];
208
+ for (const uri of value as string[]) {
209
+ try {
210
+ redirectKind(uri);
211
+ if (!kept.includes(uri)) kept.push(uri);
212
+ } catch {
213
+ dropped.push(uri);
214
+ }
215
+ }
216
+ if (kept.length === 0) throw new ClientInvalid(`none of the redirect_uris is HTTPS or HTTP on loopback: ${dropped.join(', ')}`);
217
+ return { kept, dropped };
218
+ }
219
+
220
+ /** A client's name as shown: its own, cut short, or its host when it gives none. */
221
+ export function clientName(value: unknown, fallback: string): string {
222
+ // Control and bidirectional characters could make one name look like another.
223
+ const name = typeof value === 'string' ? value.replace(/[\p{Cc}\p{Cf}]/gu, '').trim() : '';
224
+ return name === '' ? fallback : name.slice(0, MAX_NAME);
225
+ }
226
+
227
+ function redirectList(value: unknown): string[] {
228
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_REDIRECTS || value.some((uri) => typeof uri !== 'string')) {
229
+ throw new ClientInvalid(`the client metadata document must list 1 to ${MAX_REDIRECTS} redirect_uris`);
230
+ }
231
+ for (const uri of value as string[]) redirectKind(uri);
232
+ return value as string[];
233
+ }
package/src/pages.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // What coffre's server and its pages agree on outside the API: the
2
2
  // visitor's preferences, which the server reads from a request's cookies
3
- // and the browser from its own, to draw the same page; and what either says
3
+ // and the browser from its own, to draw the same page; the names a
4
+ // project's pages take, which no environment may; and what either says
4
5
  // when coffre's request middleware is missing.
5
6
 
6
7
  /**
@@ -12,6 +13,14 @@ export type Preferences = { theme: 'system' | 'light' | 'dark'; sidebar: 'expand
12
13
  /** The cookies that hold them. */
13
14
  export const PREFERENCE_COOKIES = { theme: 'coffre-theme', sidebar: 'coffre-sidebar' } as const;
14
15
 
16
+ /**
17
+ * A project's pages beside its environments': `/projects/market/settings`
18
+ * sits where `/projects/market/prod` does, so no environment takes one of
19
+ * these names (`environmentSlug` in `./schemas.ts`). A tab a project gains
20
+ * adds its word here.
21
+ */
22
+ export const PROJECT_PAGES = ['users', 'service-accounts', 'settings'] as const;
23
+
15
24
  /** The preferences in a `Cookie` header, or in `document.cookie`, which reads the same. */
16
25
  export function preferencesIn(cookies: string | null): Preferences {
17
26
  const value = (name: string) => {
package/src/schemas.ts CHANGED
@@ -1,8 +1,45 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ import { PROJECT_PAGES } from './pages.ts';
4
+
3
5
  export const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
6
+ /** An environment's slug: any slug but the name of one of its project's pages. */
7
+ export const environmentSlug = slug.refine((value) => !(PROJECT_PAGES as readonly string[]).includes(value), {
8
+ error: (issue) => `"${String(issue.input)}" is taken by a project's page, /projects/<project>/${String(issue.input)}: an environment cannot have it`,
9
+ });
10
+
11
+ /**
12
+ * A deleted project or environment keeps its row as a tombstone, under a
13
+ * slug no live place can hold: `market~deleted-2026-10-05`. The `~` is the
14
+ * whole rule: a place is deleted when its slug, or its project's, holds one.
15
+ * `@coffre/db/dialect`'s `tombstone()` is the same rule in SQL.
16
+ */
17
+ export const TOMBSTONE = '~';
18
+
19
+ export function isTombstone(slug: string): boolean {
20
+ return slug.includes(TOMBSTONE);
21
+ }
22
+
23
+ /** The slug a place deleted on `day` keeps: the `n`th that day is `market~deleted-2026-10-05-<n>`. */
24
+ export function tombstoneOf(slug: string, day: Date, n = 1): string {
25
+ const base = `${slug}${TOMBSTONE}deleted-${day.toISOString().slice(0, 10)}`;
26
+ return n === 1 ? base : `${base}-${n}`;
27
+ }
4
28
  export const displayName = z.string().trim().min(1).max(120);
29
+ /** A folder: 1 to 64 characters, no `/`, no control character, no space at either end (the tables check it too). */
30
+ export const folderName = z.string().min(1).max(64)
31
+ .refine((name) => name === name.trim(), 'a folder name cannot start or end with a space')
32
+ .refine((name) => !/[/\p{Cc}]/u.test(name), 'a folder name cannot hold "/" or a control character');
5
33
  export const secretKey = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/);
34
+ /**
35
+ * A member as people read it, in a message or on a page: a service account
36
+ * as `service:<name>`, though the API, the vault and the log keep the
37
+ * `token:<name>` their signed entries hold.
38
+ */
39
+ export function shownMember(member: string): string {
40
+ return member.startsWith('token:') ? `service:${member.slice('token:'.length)}` : member;
41
+ }
42
+
6
43
  export const principalType = z.enum(['user', 'service']);
7
44
  export const principalId = z.string().trim().min(1).max(320);
8
45
  export const instanceRole = z.enum(['user', 'owner']);
package/src/vault.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AccessFault } from './access-fault.ts';
2
- import type { Role } from './access.ts';
2
+ import type { GrantPlace, Role } from './access.ts';
3
3
 
4
4
  export { checkpointMessage, checkpointVerifier, verifyCheckpoint } from './checkpoint.ts';
5
5
  export { describeAccessFault, type AccessFault, type FaultGrant, type FaultNames } from './access-fault.ts';
@@ -36,6 +36,25 @@ export interface Vault {
36
36
  */
37
37
  rewrap(input: RewrapInput): Promise<Outcome<{ wrapped: WrappedKey[]; seqs: number[] }>>;
38
38
 
39
+ /**
40
+ * Make secrets references to others: whoever can read the holder's
41
+ * environment reads the source through it (docs/design/environments.md).
42
+ * The vault checks that `principal` reads each source and writes each
43
+ * holder, by their own grants, and logs a `reference.create` per item:
44
+ * that entry, under the vault's MAC, is the reference. `seqs` are the
45
+ * entries, which the app stores beside each reference and names when it
46
+ * reads through one (`UnwrapInput`'s `via`). All, or none.
47
+ */
48
+ reference(input: ReferenceInput): Promise<Outcome<{ seqs: number[] }>>;
49
+ /**
50
+ * End references, each with a `reference.end` naming its creation:
51
+ * `replaced` when the holder gets a value of its own, which takes write on
52
+ * the holder; `broken` otherwise, which takes write on the holder, or
53
+ * managing the source's project's access. An ended reference reads no
54
+ * more, whatever any row says. All, or none.
55
+ */
56
+ endReferences(input: EndReferencesInput): Promise<Outcome<{ seqs: number[] }>>;
57
+
39
58
  /**
40
59
  * What one principal holds right now: the app asks once per request. A
41
60
  * decision, its rows checked: lists of who holds what read the rows
@@ -112,8 +131,15 @@ export type RefusalCode =
112
131
  | 'expired'
113
132
  /** Too many unwraps in the window; see `BulkLimit`. */
114
133
  | 'bulk_limit'
115
- /** The wrapped key does not belong to the secret it was presented as. */
134
+ /** The wrapped key does not belong to the secret it was presented as, or a reference is not the vault's. */
116
135
  | 'bad_claim'
136
+ /**
137
+ * The project or environment was deleted for good: nothing under it is
138
+ * opened or wrapped again, whatever grant would cover it.
139
+ */
140
+ | 'deleted'
141
+ /** The reference read through was ended: broken, or replaced by a value. */
142
+ | 'ended'
117
143
  /** Not allowed to manage access, members or the log. */
118
144
  | 'not_allowed'
119
145
  /** Root admins come from the vault's configuration and cannot be changed. */
@@ -162,8 +188,8 @@ export type WrappedKey = { kekProvider: string; kekId: string; kekVersion: strin
162
188
  */
163
189
  export type KeyChecks = { current: { kekProvider: string; kekId: string }; checks: (WrappedKey & { seq: number })[] };
164
190
 
165
- /** Why someone reads: shown in the log, and the same rules apply to each. */
166
- export type Purpose = 'reveal' | 'run' | 'compare';
191
+ /** Why someone reads: shown in the log, and the same rules apply to each. A fork reads to `copy`. */
192
+ export type Purpose = 'reveal' | 'run' | 'compare' | 'copy';
167
193
 
168
194
  /** Ties the vault's entries to the app's request and audit rows. */
169
195
  type Correlation = {
@@ -184,7 +210,31 @@ type Correlation = {
184
210
  export type UnwrapInput = Correlation & {
185
211
  principal: string;
186
212
  purpose: Purpose;
187
- items: { secretVersionId: string }[];
213
+ /**
214
+ * `via` reads a source's current version through a reference: the vault
215
+ * checks the reader's grants on the holder's environment, as its
216
+ * `reference.create` entry names it, not on the source's.
217
+ */
218
+ items: { secretVersionId: string; via?: Via }[];
219
+ };
220
+
221
+ /** A reference, as a read names it: its id, and the seq of the vault's `reference.create` entry. */
222
+ export type Via = { reference: string; seq: number };
223
+
224
+ /** Where a reference is held: the holder's ids, and its path for the log. A new holder's row may not exist yet. */
225
+ export type HolderRef = { projectId: string; environmentId: string; secretId: string; path: string };
226
+
227
+ export type ReferenceInput = Correlation & {
228
+ principal: string;
229
+ /** `id` is the reference's, a fresh UUID; the vault reads the source by its id. */
230
+ items: { id: string; holder: HolderRef; source: { secretId: string } }[];
231
+ };
232
+
233
+ export type EndReferencesInput = Correlation & {
234
+ principal: string;
235
+ /** `abandoned`: sealed for a write that stored nothing, so that no row written later revives it. */
236
+ reason: 'replaced' | 'broken' | 'abandoned';
237
+ items: Via[];
188
238
  };
189
239
 
190
240
  export type WrapInput = Correlation & {
@@ -199,10 +249,12 @@ export type RewrapInput = Correlation & {
199
249
  items: { secret: SecretRef; secretVersionId: string }[];
200
250
  };
201
251
 
202
- export type Grant = {
203
- projectId: string;
204
- /** Null for the whole project. */
205
- environmentId: string | null;
252
+ /**
253
+ * A grant: on a project, one of its environments (`projectId` is the
254
+ * environment's project), every project, or one environment slug in every
255
+ * project (`GrantPlace` in @coffre/core/access).
256
+ */
257
+ export type Grant = GrantPlace & {
206
258
  role: Role;
207
259
  /** ISO 8601, or null for no end. */
208
260
  expiresAt: string | null;
@@ -230,10 +282,13 @@ export type Access = {
230
282
  by: string | null;
231
283
  };
232
284
 
233
- /** One place: a role to hold there, with an optional end, or null for none. */
234
- export type GrantChange = {
235
- projectId: string;
236
- environmentId: string | null;
285
+ /**
286
+ * One place: a role to hold there, with an optional end, or null for none.
287
+ * An environment's place names its project too; `environmentSlug` left out
288
+ * is null.
289
+ */
290
+ export type GrantChange = Omit<GrantPlace, 'environmentSlug'> & {
291
+ environmentSlug?: string | null;
237
292
  role: Role | null;
238
293
  expiresAt: string | null;
239
294
  };