@coffre/core 0.0.0 → 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -3
  3. package/dist/access.d.ts +100 -0
  4. package/dist/access.js +109 -0
  5. package/dist/audit.d.ts +119 -0
  6. package/dist/audit.js +160 -0
  7. package/dist/dotenv.d.ts +45 -0
  8. package/dist/dotenv.js +194 -0
  9. package/dist/envelope.d.ts +50 -0
  10. package/dist/envelope.js +83 -0
  11. package/dist/identity.d.ts +433 -0
  12. package/dist/identity.js +691 -0
  13. package/dist/kek.d.ts +72 -0
  14. package/dist/kek.js +486 -0
  15. package/dist/registry-C0NGpxcc.d.ts +126 -0
  16. package/dist/schemas.d.ts +18 -0
  17. package/dist/schemas.js +13 -0
  18. package/dist/types-Mi7-Tzj9.js +57 -0
  19. package/dist/vault.d.ts +375 -0
  20. package/dist/vault.js +45 -0
  21. package/package.json +75 -5
  22. package/src/access-fault.ts +61 -0
  23. package/src/access.ts +152 -0
  24. package/src/audit/chain.ts +207 -0
  25. package/src/checkpoint.ts +13 -0
  26. package/src/context.ts +54 -0
  27. package/src/dotenv.ts +242 -0
  28. package/src/envelope.ts +127 -0
  29. package/src/identity/auth.ts +124 -0
  30. package/src/identity/index.ts +6 -0
  31. package/src/identity/signin/config.ts +301 -0
  32. package/src/identity/signin/github.ts +191 -0
  33. package/src/identity/signin/index.ts +5 -0
  34. package/src/identity/signin/oidc.ts +199 -0
  35. package/src/identity/signin/sealed.ts +55 -0
  36. package/src/identity/signin/types.ts +94 -0
  37. package/src/identity/tokens.ts +45 -0
  38. package/src/identity/types.ts +51 -0
  39. package/src/identity/verifier.ts +86 -0
  40. package/src/kek/aws-kms.ts +279 -0
  41. package/src/kek/cancellation.ts +56 -0
  42. package/src/kek/index.ts +6 -0
  43. package/src/kek/local.ts +122 -0
  44. package/src/kek/registry.ts +63 -0
  45. package/src/kek/sigv4.ts +75 -0
  46. package/src/kek/types.ts +79 -0
  47. package/src/schemas.ts +11 -0
  48. package/src/vault.ts +276 -0
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ //#region src/schemas.d.ts
3
+ export declare const slug: z.ZodString;
4
+ export declare const displayName: z.ZodString;
5
+ export declare const secretKey: z.ZodString;
6
+ export declare const principalType: z.ZodEnum<{
7
+ user: "user";
8
+ service: "service";
9
+ }>;
10
+ export declare const principalId: z.ZodString;
11
+ export declare const instanceRole: z.ZodEnum<{
12
+ owner: "owner";
13
+ user: "user";
14
+ }>;
15
+ export declare const grantId: z.ZodString;
16
+ export declare const emailAddress: z.ZodString;
17
+ export declare const syncId: z.ZodString;
18
+ //#endregion
@@ -0,0 +1,13 @@
1
+ import { z } from "zod";
2
+ //#region src/schemas.ts
3
+ const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/);
4
+ const displayName = z.string().trim().min(1).max(120);
5
+ const secretKey = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,127}$/);
6
+ const principalType = z.enum(["user", "service"]);
7
+ const principalId = z.string().trim().min(1).max(320);
8
+ const instanceRole = z.enum(["user", "owner"]);
9
+ const grantId = z.string().uuid();
10
+ const emailAddress = z.string().email().max(320);
11
+ const syncId = z.string().uuid();
12
+ //#endregion
13
+ export { displayName, emailAddress, grantId, instanceRole, principalId, principalType, secretKey, slug, syncId };
@@ -0,0 +1,57 @@
1
+ //#region src/context.ts
2
+ /**
3
+ * Version prefix for the AAD encoding. If the encoding ever changes, this
4
+ * changes with it, and old ciphertexts keep decrypting under the old rule.
5
+ */
6
+ const AAD_VERSION = "coffre.aad.v1";
7
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
8
+ function assertUuid(value, field) {
9
+ if (typeof value !== "string" || !UUID_RE.test(value)) throw new Error(`context.${field} must be a lowercase UUID, got: ${String(value)}`);
10
+ }
11
+ /** Throw unless every field is a lowercase UUID. */
12
+ function checkContext(ctx) {
13
+ assertUuid(ctx.projectId, "projectId");
14
+ assertUuid(ctx.environmentId, "environmentId");
15
+ assertUuid(ctx.secretId, "secretId");
16
+ }
17
+ /**
18
+ * Encode a context into AAD bytes.
19
+ *
20
+ * Every field is validated as a UUID first, which is what makes joining with
21
+ * `|` unambiguous: a UUID cannot contain the separator, so no two distinct
22
+ * contexts can encode to the same bytes.
23
+ */
24
+ function encodeAad(ctx) {
25
+ checkContext(ctx);
26
+ return Buffer.from(`${AAD_VERSION}|${ctx.projectId}|${ctx.environmentId}|${ctx.secretId}`, "utf8");
27
+ }
28
+ //#endregion
29
+ //#region src/kek/types.ts
30
+ /** Length of the data encryption keys we generate, in bytes (AES-256). */
31
+ const DEK_BYTES = 32;
32
+ /**
33
+ * The key service could not answer: it is down, throttling past the
34
+ * retries, or refusing coffre's own credentials. Nothing is known about the
35
+ * wrapped key, so the vault fails the call instead of refusing it.
36
+ */
37
+ var KekUnavailableError = class extends Error {
38
+ name = "KekUnavailableError";
39
+ uncertain;
40
+ constructor(message, uncertain = false) {
41
+ super(message);
42
+ this.uncertain = uncertain;
43
+ }
44
+ };
45
+ /** No more work may start. A request already sent may have been accepted by KMS. */
46
+ var KekCancelledError = class extends KekUnavailableError {
47
+ name = "KekCancelledError";
48
+ constructor(uncertain = false) {
49
+ super("key operation was cancelled", uncertain);
50
+ }
51
+ };
52
+ /** The ciphertext does not open under the claimed key and context. */
53
+ var KekBadClaimError = class extends Error {
54
+ name = "KekBadClaimError";
55
+ };
56
+ //#endregion
57
+ export { AAD_VERSION as a, KekUnavailableError as i, KekBadClaimError as n, checkContext as o, KekCancelledError as r, encodeAad as s, DEK_BYTES as t };
@@ -0,0 +1,375 @@
1
+ import { Role } from "./access.js";
2
+ //#region src/access-fault.d.ts
3
+ /**
4
+ * Why the vault's members and grants do not follow from its log, as facts
5
+ * rather than a sentence. The vault knows only ids; the app knows what the
6
+ * people and places are called, so it words the same fault with names.
7
+ */
8
+ type AccessFault =
9
+ /** An entry changes someone the log never admitted. */
10
+ {
11
+ kind: 'unadmitted-change';
12
+ seq: number;
13
+ principal: string;
14
+ } |
15
+ /** The store has a member the log never admitted. */
16
+ {
17
+ kind: 'unlogged-member';
18
+ principal: string;
19
+ } |
20
+ /** The log admits someone the store does not have. */
21
+ {
22
+ kind: 'missing-member';
23
+ principal: string;
24
+ } |
25
+ /** The store's row for someone differs from the log's in these fields. */
26
+ {
27
+ kind: 'member-differs';
28
+ principal: string;
29
+ fields: string[];
30
+ } |
31
+ /** The store holds a grant the log never gave. */
32
+ {
33
+ kind: 'unlogged-grant';
34
+ grant: FaultGrant;
35
+ } |
36
+ /** The log gives a grant the store does not hold. */
37
+ {
38
+ kind: 'missing-grant';
39
+ grant: FaultGrant;
40
+ } |
41
+ /**
42
+ * The store's row for someone fails the vault's integrity check: changed
43
+ * outside the vault (`mac`), or put back from before the log's last change
44
+ * to them (`stale`).
45
+ */
46
+ {
47
+ kind: 'tampered-member';
48
+ principal: string;
49
+ why: 'mac' | 'stale';
50
+ };
51
+ type FaultGrant = {
52
+ principal: string;
53
+ projectId: string;
54
+ environmentId: string | null;
55
+ role: string;
56
+ };
57
+ /** How to name a principal (`user:ada@…`) and a place; ids by default. */
58
+ type FaultNames = {
59
+ principal?: (principal: string) => string;
60
+ place?: (projectId: string, environmentId: string | null) => string;
61
+ };
62
+ /** The fault in a sentence, with whatever names the caller can give. */
63
+ export declare function describeAccessFault(fault: AccessFault, names?: FaultNames): string;
64
+ //#endregion
65
+ //#region src/checkpoint.d.ts
66
+ /** What a checkpoint's signature covers: the prefix of the log up to entry `seq`, and when. */
67
+ export declare function checkpointMessage(checkpoint: Omit<Checkpoint, 'signature' | 'keyId'>): Uint8Array<ArrayBuffer>;
68
+ /** Whether `checkpoint` was signed by the key whose raw public half is `publicKey`. */
69
+ export declare function verifyCheckpoint(checkpoint: Checkpoint, publicKey: string): Promise<boolean>;
70
+ //#endregion
71
+ //#region src/vault.d.ts
72
+ /**
73
+ * The vault as the app sees it: the contract between `@coffre/server` and
74
+ * `@coffre/vault`, kept in core so that neither side owns it. Every argument
75
+ * and result is plain JSON: strings, numbers, booleans, arrays and objects,
76
+ * never a Buffer, a Date, a class or a function. So it crosses a Workers RPC
77
+ * boundary, or a process boundary later, as it is, and the in-process
78
+ * transport proves it by round-tripping everything through JSON.
79
+ *
80
+ * The app says who is asking and the vault decides. The app never holds a
81
+ * key: it encrypts a value under a fresh data key, has the vault wrap that
82
+ * key, and stores the result; to read, it names stored versions and the
83
+ * vault loads their keys itself.
84
+ *
85
+ * Decisions come back as values: `{ ok: false, refusal }` for a refusal, which
86
+ * the vault has already logged. Only a fault (a bug, the store down) throws.
87
+ */
88
+ export interface Vault {
89
+ /** Unwrap data keys for `principal`: all of them, or none. */
90
+ unwrap(input: UnwrapInput): Promise<Outcome<{
91
+ keys: string[];
92
+ }>>;
93
+ /**
94
+ * Wrap fresh data keys for new versions `principal` writes: all, or none.
95
+ * `seqs` are the `key.wrap` entries, one per key, which the app's
96
+ * `secret.write` entries name as related.
97
+ */
98
+ wrap(input: WrapInput): Promise<Outcome<{
99
+ wrapped: WrappedKey[];
100
+ seqs: number[];
101
+ }>>;
102
+ /**
103
+ * Wrap existing data keys again for new versions of the same secrets,
104
+ * under the current key: restoring an old value. It needs a write grant,
105
+ * not a read one, since the key never leaves the vault.
106
+ */
107
+ rewrap(input: RewrapInput): Promise<Outcome<{
108
+ wrapped: WrappedKey[];
109
+ seqs: number[];
110
+ }>>;
111
+ /**
112
+ * What one principal holds right now: the app asks once per request. A
113
+ * decision, its rows checked: lists of who holds what read the rows
114
+ * themselves, and need not ask.
115
+ */
116
+ access(principal: string): Promise<Access>;
117
+ /** Set roles at several places for one principal: all of it, or none. */
118
+ setAccess(input: SetAccessInput): Promise<Outcome<{
119
+ changes: AccessChange[];
120
+ }>>;
121
+ /**
122
+ * Add a member, bring back a removed one, or change whether they are an
123
+ * owner. `generation` is theirs now: sessions and tokens of an earlier
124
+ * one are dead.
125
+ */
126
+ admit(input: AdmitInput): Promise<Outcome<{
127
+ created: boolean;
128
+ owner: boolean;
129
+ generation: number;
130
+ }>>;
131
+ /** Remove a member: revoke every grant and refuse them until admitted again, from `generation` on. */
132
+ remove(input: RemoveInput): Promise<Outcome<{
133
+ revoked: Grant[];
134
+ generation: number;
135
+ }>>;
136
+ /**
137
+ * Sign the log up to its last entry, in an `audit.checkpoint` entry of
138
+ * the vault's, if the whole chain recomputes from its first entry, the
139
+ * vault's entries by their MACs, and the prefix the last checkpoint signed
140
+ * is still there. The vault reads the log itself: it takes nobody's word
141
+ * for where it ends, or for what came before. It checks every
142
+ * member's row too, as `access` would, and logs a `vault.tampered` for
143
+ * each one changed around it: lists read rows without asking the vault.
144
+ */
145
+ checkpoint(): Promise<Outcome<{
146
+ checkpoint: Checkpoint;
147
+ }>>;
148
+ /**
149
+ * What only the vault's configuration says, which the app shows: the key
150
+ * its checkpoints verify under, and the root admins, as principals.
151
+ */
152
+ about(): Promise<{
153
+ publicKey: string;
154
+ rootAdmins: string[];
155
+ }>;
156
+ /**
157
+ * Check the whole log: rehash it from the first entry, the vault's
158
+ * entries by their MACs, every checkpoint against the prefix it signed,
159
+ * and replay it to see that who is a member, and what they hold, follow
160
+ * from it. A verdict and nothing else, so anyone may ask; the app asks
161
+ * for owners.
162
+ */
163
+ verifyLog(input: VerifyLogInput): Promise<LogVerification>;
164
+ }
165
+ /**
166
+ * The vault's entries that change what a member holds: what their row's
167
+ * `access_seq` names, and what a finding about them is newer than while it
168
+ * stands.
169
+ */
170
+ export declare const ACCESS_ACTIONS: readonly ["member.add", "member.restore", "member.owner", "member.remove", "access.grant", "access.revoke"];
171
+ /** A refusal, already logged by the time the app sees it. */
172
+ export type Refusal = {
173
+ code: RefusalCode;
174
+ message: string;
175
+ };
176
+ export type RefusalCode =
177
+ /** The principal was removed; only `admit` brings them back. */
178
+ 'removed' |
179
+ /** The principal was never admitted. */
180
+ 'not_a_member' |
181
+ /** No grant covers the place for what was asked. */
182
+ 'no_grant' |
183
+ /** A grant covered it, but has expired. */
184
+ 'expired' |
185
+ /** Too many unwraps in the window; see `BulkLimit`. */
186
+ 'bulk_limit' |
187
+ /** The wrapped key does not belong to the secret it was presented as. */
188
+ 'bad_claim' |
189
+ /** Not allowed to manage access, members or the log. */
190
+ 'not_allowed' |
191
+ /** Root admins come from the vault's configuration and cannot be changed. */
192
+ 'root_admin' |
193
+ /** Well-formed, but not something the rules allow: a project role on an environment, an owner token. */
194
+ 'invalid' |
195
+ /** The vault's own log no longer carries the head the last checkpoint signed, or does not rehash since. */
196
+ 'log_broken' |
197
+ /**
198
+ * A KEK the vault is given does not open what it wrapped: a restore with
199
+ * the wrong key, or a key mistyped under the right id. Every key
200
+ * operation is refused until the vault restarts with the right one.
201
+ */
202
+ 'wrong_kek' |
203
+ /**
204
+ * The member's row or grants were changed outside the vault, or put back
205
+ * from before a later change: it refuses them until an owner removes them,
206
+ * which starts their access over from the log.
207
+ */
208
+ 'tampered';
209
+ export type Outcome<T> = ({
210
+ ok: true;
211
+ } & T) | {
212
+ ok: false;
213
+ refusal: Refusal;
214
+ };
215
+ /**
216
+ * A proposed version the app will store. The ids bind its key, and `path`
217
+ * is the app's label for the write's log. Reads name stored version ids
218
+ * instead, so the vault reads the binding and wrapped key itself.
219
+ */
220
+ export type SecretRef = {
221
+ projectId: string;
222
+ environmentId: string;
223
+ secretId: string;
224
+ version: number;
225
+ /** `market/prod/DATABASE_URL`. */
226
+ path: string;
227
+ };
228
+ /** A wrapped data key and the key encryption key that wrapped it; `bytes` is base64. */
229
+ export type WrappedKey = {
230
+ kekProvider: string;
231
+ kekId: string;
232
+ kekVersion: string;
233
+ bytes: string;
234
+ };
235
+ /** Why someone reads: shown in the log, and the same rules apply to each. */
236
+ export type Purpose = 'reveal' | 'run' | 'compare' | 'sync';
237
+ /** Ties the vault's entries to the app's request and audit rows. */
238
+ type Correlation = {
239
+ requestId?: string | null;
240
+ /**
241
+ * The one action the call is part of, the app's id for it: a reveal's
242
+ * reads, a write's versions, an access change. Its entries share it.
243
+ */
244
+ operationId?: string | null;
245
+ };
246
+ export type UnwrapInput = Correlation & {
247
+ principal: string;
248
+ purpose: Purpose;
249
+ items: {
250
+ secretVersionId: string;
251
+ }[];
252
+ };
253
+ export type WrapInput = Correlation & {
254
+ principal: string;
255
+ /** `key` is a fresh 32-byte data key, base64. */
256
+ items: {
257
+ secret: SecretRef;
258
+ key: string;
259
+ }[];
260
+ };
261
+ export type RewrapInput = Correlation & {
262
+ principal: string;
263
+ /** `secret` is the proposed new version; `secretVersionId` is the stored source. */
264
+ items: {
265
+ secret: SecretRef;
266
+ secretVersionId: string;
267
+ }[];
268
+ };
269
+ export type Grant = {
270
+ projectId: string;
271
+ /** Null for the whole project. */
272
+ environmentId: string | null;
273
+ role: Role;
274
+ /** ISO 8601, or null for no end. */
275
+ expiresAt: string | null;
276
+ grantedAt: string;
277
+ grantedBy: string;
278
+ };
279
+ export type Access = {
280
+ principal: string;
281
+ /**
282
+ * `unknown` for someone never admitted, `tampered` for a member whose row
283
+ * fails the vault's integrity check: refused everything, as `removed` is.
284
+ * Root admins are always active.
285
+ */
286
+ status: 'active' | 'removed' | 'unknown' | 'tampered';
287
+ /** Advanced by removal, even if the app cannot commit its credential revocations. */
288
+ generation: number;
289
+ isRootAdmin: boolean;
290
+ /** Root admins and users admitted as owners, while active. */
291
+ isOwner: boolean;
292
+ /** Live grants only, and none unless active. */
293
+ grants: Grant[];
294
+ /** When the status last changed, and who changed it; null for root admins and strangers. */
295
+ since: string | null;
296
+ by: string | null;
297
+ };
298
+ /** One place: a role to hold there, with an optional end, or null for none. */
299
+ export type GrantChange = {
300
+ projectId: string;
301
+ environmentId: string | null;
302
+ role: Role | null;
303
+ expiresAt: string | null;
304
+ };
305
+ export type AccessChange = 'created' | 'updated' | 'revoked' | 'unchanged';
306
+ export type SetAccessInput = Correlation & {
307
+ actor: string;
308
+ principal: string;
309
+ changes: GrantChange[];
310
+ };
311
+ export type AdmitInput = Correlation & {
312
+ actor: string;
313
+ principal: string;
314
+ /** Left out, an existing member keeps their role and a new one is not an owner. */
315
+ owner?: boolean;
316
+ };
317
+ export type RemoveInput = Correlation & {
318
+ actor: string;
319
+ principal: string;
320
+ /**
321
+ * For a sync: the environment it pushes from. Whoever may manage it there
322
+ * may stop the sync, also once its grant there is revoked and only the
323
+ * one on its credential is left.
324
+ */
325
+ source?: {
326
+ projectId: string;
327
+ environmentId: string;
328
+ };
329
+ };
330
+ /**
331
+ * A prefix of the log the vault signed: every entry up to `seq`, the last
332
+ * of which has `hash`, which pins every byte before it. Kept as the vault's
333
+ * `audit.checkpoint` entry, just after the prefix it signs.
334
+ */
335
+ export type Checkpoint = {
336
+ seq: number;
337
+ /** The hash of entry `seq`, hex. */
338
+ hash: string;
339
+ signedAt: string;
340
+ keyId: string;
341
+ /** Ed25519 over `checkpointMessage(...)`, base64. */
342
+ signature: string;
343
+ };
344
+ /** An entry of the vault's log, and so the chain up to it. */
345
+ export type LogHead = {
346
+ seq: number;
347
+ hash: string;
348
+ };
349
+ export type VerifyLogInput = {
350
+ /**
351
+ * The last entry the app verified, its own entries by its key: the vault
352
+ * checks the log still holds it, so between them both authors' entries
353
+ * are authenticated over the same prefix.
354
+ */
355
+ upTo?: LogHead | null;
356
+ };
357
+ /**
358
+ * Whether the vault's log holds, with the number of entries in it. On a
359
+ * failure, the entry where it breaks, or null when the chain holds but the
360
+ * members and grants do not follow from it. Then `fault` says which, as
361
+ * facts the app can word with names: `reason` has only ids.
362
+ * Full checks report live key batches in `pending`; overdue ones fail.
363
+ */
364
+ export type LogVerification = {
365
+ ok: true;
366
+ entries: number;
367
+ pending?: number;
368
+ } | {
369
+ ok: false;
370
+ failedAtSeq: number | null;
371
+ reason: string;
372
+ fault?: AccessFault;
373
+ };
374
+ //#endregion
375
+ export type { AccessFault, FaultGrant, FaultNames };
package/dist/vault.js ADDED
@@ -0,0 +1,45 @@
1
+ //#region src/checkpoint.ts
2
+ /** What a checkpoint's signature covers: the prefix of the log up to entry `seq`, and when. */
3
+ function checkpointMessage(checkpoint) {
4
+ const { seq, hash, signedAt } = checkpoint;
5
+ return new Uint8Array(new TextEncoder().encode(`coffre.checkpoint.v3|${seq}|${hash}|${signedAt}`));
6
+ }
7
+ /** Whether `checkpoint` was signed by the key whose raw public half is `publicKey`. */
8
+ async function verifyCheckpoint(checkpoint, publicKey) {
9
+ const key = await crypto.subtle.importKey("raw", new Uint8Array(Buffer.from(publicKey, "base64")), { name: "Ed25519" }, false, ["verify"]);
10
+ return crypto.subtle.verify("Ed25519", key, new Uint8Array(Buffer.from(checkpoint.signature, "base64")), checkpointMessage(checkpoint));
11
+ }
12
+ //#endregion
13
+ //#region src/access-fault.ts
14
+ /** The fault in a sentence, with whatever names the caller can give. */
15
+ function describeAccessFault(fault, names = {}) {
16
+ const who = names.principal ?? ((principal) => principal);
17
+ const where = names.place ?? ((projectId, environmentId) => environmentId === null ? projectId : `${projectId}/${environmentId}`);
18
+ const grant = ({ principal, projectId, environmentId, role }) => `${who(principal)} as ${role} on ${where(projectId, environmentId)}`;
19
+ switch (fault.kind) {
20
+ case "unadmitted-change": return `entry ${fault.seq} changes ${who(fault.principal)}, whom the log never admitted`;
21
+ case "unlogged-member": return `the store has ${who(fault.principal)} as a member, whom the log never admitted`;
22
+ case "missing-member": return `the log admits ${who(fault.principal)}, whom the store does not have`;
23
+ case "member-differs": return `the store's ${who(fault.principal)} differs from the log's in ${fault.fields.join(", ")}`;
24
+ case "unlogged-grant": return `the store holds a grant the log never gave: ${grant(fault.grant)}`;
25
+ case "missing-grant": return `the log gives a grant the store does not hold: ${grant(fault.grant)}`;
26
+ case "tampered-member": return fault.why === "mac" ? `the store's ${who(fault.principal)}, or their grants, were changed outside the vault` : `the store's ${who(fault.principal)} is older than the log's last change to them: it was put back`;
27
+ }
28
+ }
29
+ //#endregion
30
+ //#region src/vault.ts
31
+ /**
32
+ * The vault's entries that change what a member holds: what their row's
33
+ * `access_seq` names, and what a finding about them is newer than while it
34
+ * stands.
35
+ */
36
+ const ACCESS_ACTIONS = [
37
+ "member.add",
38
+ "member.restore",
39
+ "member.owner",
40
+ "member.remove",
41
+ "access.grant",
42
+ "access.revoke"
43
+ ];
44
+ //#endregion
45
+ export { ACCESS_ACTIONS, checkpointMessage, describeAccessFault, verifyCheckpoint };
package/package.json CHANGED
@@ -1,8 +1,78 @@
1
1
  {
2
2
  "name": "@coffre/core",
3
- "version": "0.0.0",
4
- "description": "Reserved for coffre, a self-hosted secrets manager. The first release replaces this placeholder.",
3
+ "version": "0.1.1",
4
+ "description": "What coffre's packages share: roles, envelopes, identity, the audit chain and the vault's contract.",
5
5
  "license": "MIT",
6
- "homepage": "https://github.com/erwinkn/coffre",
7
- "repository": { "type": "git", "url": "git+https://github.com/erwinkn/coffre.git", "directory": "packages/core" }
8
- }
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/erwinkn/coffre.git",
9
+ "directory": "packages/core"
10
+ },
11
+ "type": "module",
12
+ "engines": {
13
+ "node": ">=24"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src"
18
+ ],
19
+ "exports": {
20
+ "./access": {
21
+ "coffre:source": "./src/access.ts",
22
+ "types": "./dist/access.d.ts",
23
+ "default": "./dist/access.js"
24
+ },
25
+ "./audit": {
26
+ "coffre:source": "./src/audit/chain.ts",
27
+ "types": "./dist/audit.d.ts",
28
+ "default": "./dist/audit.js"
29
+ },
30
+ "./dotenv": {
31
+ "coffre:source": "./src/dotenv.ts",
32
+ "types": "./dist/dotenv.d.ts",
33
+ "default": "./dist/dotenv.js"
34
+ },
35
+ "./envelope": {
36
+ "coffre:source": "./src/envelope.ts",
37
+ "types": "./dist/envelope.d.ts",
38
+ "default": "./dist/envelope.js"
39
+ },
40
+ "./identity": {
41
+ "coffre:source": "./src/identity/index.ts",
42
+ "types": "./dist/identity.d.ts",
43
+ "default": "./dist/identity.js"
44
+ },
45
+ "./kek": {
46
+ "coffre:source": "./src/kek/index.ts",
47
+ "types": "./dist/kek.d.ts",
48
+ "default": "./dist/kek.js"
49
+ },
50
+ "./schemas": {
51
+ "coffre:source": "./src/schemas.ts",
52
+ "types": "./dist/schemas.d.ts",
53
+ "default": "./dist/schemas.js"
54
+ },
55
+ "./vault": {
56
+ "coffre:source": "./src/vault.ts",
57
+ "types": "./dist/vault.d.ts",
58
+ "default": "./dist/vault.js"
59
+ },
60
+ "./package.json": "./package.json"
61
+ },
62
+ "dependencies": {
63
+ "jose": "6.2.3",
64
+ "oauth4webapi": "3.8.8",
65
+ "zod": "4.4.3"
66
+ },
67
+ "devDependencies": {
68
+ "@types/node": "26.1.1",
69
+ "tsdown": "0.23.0",
70
+ "typescript": "5.9.3",
71
+ "@coffre/conformance": "0.1.1"
72
+ },
73
+ "scripts": {
74
+ "build": "tsdown",
75
+ "typecheck": "tsc --noEmit",
76
+ "test": "node --conditions=coffre:source --test"
77
+ }
78
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Why the vault's members and grants do not follow from its log, as facts
3
+ * rather than a sentence. The vault knows only ids; the app knows what the
4
+ * people and places are called, so it words the same fault with names.
5
+ */
6
+ export type AccessFault =
7
+ /** An entry changes someone the log never admitted. */
8
+ | { kind: 'unadmitted-change'; seq: number; principal: string }
9
+ /** The store has a member the log never admitted. */
10
+ | { kind: 'unlogged-member'; principal: string }
11
+ /** The log admits someone the store does not have. */
12
+ | { kind: 'missing-member'; principal: string }
13
+ /** The store's row for someone differs from the log's in these fields. */
14
+ | { kind: 'member-differs'; principal: string; fields: string[] }
15
+ /** The store holds a grant the log never gave. */
16
+ | { kind: 'unlogged-grant'; grant: FaultGrant }
17
+ /** The log gives a grant the store does not hold. */
18
+ | { kind: 'missing-grant'; grant: FaultGrant }
19
+ /**
20
+ * The store's row for someone fails the vault's integrity check: changed
21
+ * outside the vault (`mac`), or put back from before the log's last change
22
+ * to them (`stale`).
23
+ */
24
+ | { kind: 'tampered-member'; principal: string; why: 'mac' | 'stale' };
25
+
26
+ export type FaultGrant = { principal: string; projectId: string; environmentId: string | null; role: string };
27
+
28
+ /** How to name a principal (`user:ada@…`) and a place; ids by default. */
29
+ export type FaultNames = {
30
+ principal?: (principal: string) => string;
31
+ place?: (projectId: string, environmentId: string | null) => string;
32
+ };
33
+
34
+ /** The fault in a sentence, with whatever names the caller can give. */
35
+ export function describeAccessFault(fault: AccessFault, names: FaultNames = {}): string {
36
+ const who = names.principal ?? ((principal: string) => principal);
37
+ const where =
38
+ names.place ??
39
+ ((projectId: string, environmentId: string | null) =>
40
+ environmentId === null ? projectId : `${projectId}/${environmentId}`);
41
+ const grant = ({ principal, projectId, environmentId, role }: FaultGrant) =>
42
+ `${who(principal)} as ${role} on ${where(projectId, environmentId)}`;
43
+ switch (fault.kind) {
44
+ case 'unadmitted-change':
45
+ return `entry ${fault.seq} changes ${who(fault.principal)}, whom the log never admitted`;
46
+ case 'unlogged-member':
47
+ return `the store has ${who(fault.principal)} as a member, whom the log never admitted`;
48
+ case 'missing-member':
49
+ return `the log admits ${who(fault.principal)}, whom the store does not have`;
50
+ case 'member-differs':
51
+ return `the store's ${who(fault.principal)} differs from the log's in ${fault.fields.join(', ')}`;
52
+ case 'unlogged-grant':
53
+ return `the store holds a grant the log never gave: ${grant(fault.grant)}`;
54
+ case 'missing-grant':
55
+ return `the log gives a grant the store does not hold: ${grant(fault.grant)}`;
56
+ case 'tampered-member':
57
+ return fault.why === 'mac'
58
+ ? `the store's ${who(fault.principal)}, or their grants, were changed outside the vault`
59
+ : `the store's ${who(fault.principal)} is older than the log's last change to them: it was put back`;
60
+ }
61
+ }