@coffre/vault 0.14.4 → 0.15.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.
Files changed (45) hide show
  1. package/README.md +6 -4
  2. package/dist/cloudflare.d.ts +343 -9
  3. package/dist/cloudflare.js +802 -6
  4. package/dist/{index-CIq4i_KT.d.ts → index-DQ-FKVF3.d.ts} +4 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/log-entry-BYWqj3O5.d.ts +364 -0
  8. package/dist/node.d.ts +182 -7
  9. package/dist/node.js +358 -16
  10. package/dist/{src-NM5NsYFs.js → src-DheG92EM.js} +9 -2
  11. package/dist/vault-kmtojYnW.js +14623 -0
  12. package/package.json +4 -5
  13. package/src/bulk.ts +89 -0
  14. package/src/changes.ts +478 -0
  15. package/src/checkpointer-node.ts +342 -0
  16. package/src/checkpointer-workers.ts +514 -0
  17. package/src/checkpointer.ts +1165 -0
  18. package/src/cloudflare.ts +207 -13
  19. package/src/config.ts +15 -3
  20. package/src/credentials.postgres.ts +78 -0
  21. package/src/credentials.sqlite.ts +240 -0
  22. package/src/credentials.ts +630 -0
  23. package/src/cutover.ts +275 -0
  24. package/src/decide.ts +437 -2
  25. package/src/inputs.ts +499 -0
  26. package/src/local.ts +20 -4
  27. package/src/log-checkpointer.ts +116 -0
  28. package/src/log-entry.ts +113 -0
  29. package/src/log-replay.ts +188 -0
  30. package/src/log-store.ts +695 -0
  31. package/src/node.ts +73 -9
  32. package/src/objects.ts +141 -0
  33. package/src/reads.ts +502 -0
  34. package/src/retention.ts +229 -0
  35. package/src/snapshot.postgres.ts +39 -9
  36. package/src/snapshot.sqlite.ts +28 -3
  37. package/src/snapshot.ts +72 -12
  38. package/src/store.ts +862 -37
  39. package/src/use.postgres.ts +111 -0
  40. package/src/use.sqlite.ts +542 -0
  41. package/src/use.ts +1060 -0
  42. package/src/vault.ts +1513 -240
  43. package/src/verifier.ts +136 -0
  44. package/src/witness-workers.ts +163 -0
  45. package/dist/vault-BBdXsny3.js +0 -5077
package/src/inputs.ts ADDED
@@ -0,0 +1,499 @@
1
+ import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
2
+
3
+ import { INSTANCE_ROLES, normalDefinition, ROLES, type CallerKind, type CredentialScope, type InstanceRole, type RoleDefinition, type Scope } from '@coffre/core/access';
4
+ import type { WrappedKey } from '@coffre/core/vault';
5
+ import { logObject } from '@coffre/db/log';
6
+
7
+ import { MAX_BULK_WINDOW_MINUTES, type BulkLimit } from './config.ts';
8
+
9
+ /**
10
+ * Every input a decision reads, as one object of the log, sealed (0.15,
11
+ * docs/design/sealed-inputs.md). Nothing calls this yet: it is the pure
12
+ * code that the change path, the use path and the verifier will share.
13
+ *
14
+ * - **principal:** a member, their instance role and its scope, and every
15
+ * grant they hold, lapsed ones included.
16
+ * - **credential:** a session, token or binding: whose it is, what kind,
17
+ * its scope, its end, and whether it was revoked.
18
+ * - **connection:** an MCP client a person connected (T147): whose, at
19
+ * which generation, its end, and whether it was revoked. A use through
20
+ * it weighs it as a person's credential of no scope.
21
+ * - **secret:** a key's current version: where it is, its nature, its
22
+ * wrapped data key or a plain value's digest, or, for a reference, its
23
+ * source while its reference stands.
24
+ * - **environment:** its project and slug, whether it is protected, a
25
+ * fork's parent, a personal config's owner, whether it was deleted, and
26
+ * whether it or its project is archived.
27
+ * - **roles:** the role tables, as an Owner edited them.
28
+ * - **policy:** what the deployment set that every decision reads: the
29
+ * bulk cap's count and window (R52).
30
+ *
31
+ * Each object's row carries its **seal**, the vault's HMAC-SHA256 over it
32
+ * cut to 16 bytes, and each change records the seal it leaves. A row is
33
+ * `tampered` if its seal is not the vault's over it, and `stale` if it is
34
+ * not the seal of its object's newest change: a row put back from before a
35
+ * revoke, validly sealed once, is refused.
36
+ */
37
+
38
+ /** A principal's grant, as its object holds it: where, which role, and until when (null: for good). */
39
+ export type InputGrant = {
40
+ projectId: string | null;
41
+ environmentId: string | null;
42
+ /** A grant on every project of 0.4: the one environment slug it covers in each, if any. */
43
+ environmentSlug: string | null;
44
+ role: string;
45
+ expiresAt: number | null;
46
+ };
47
+
48
+ export type PrincipalInput = {
49
+ kind: 'principal';
50
+ principal: string;
51
+ status: 'active' | 'removed';
52
+ /**
53
+ * Moved on by each removal (`member.remove`), kept by a restore: a
54
+ * credential issued at an earlier one is no longer its account's, before
55
+ * the removal's sweep revokes it (formal/Credentials.tla,
56
+ * UseDecidesGeneration).
57
+ */
58
+ generation: number;
59
+ role: InstanceRole;
60
+ scope: Scope;
61
+ grants: readonly InputGrant[];
62
+ };
63
+
64
+ export type CredentialInput = {
65
+ kind: 'credential';
66
+ id: string;
67
+ principal: string;
68
+ caller: CallerKind;
69
+ /** Past which nothing reaches; null for a session's. */
70
+ scope: CredentialScope | null;
71
+ expiresAt: number | null;
72
+ revoked: boolean;
73
+ /** Its account's generation when it was issued: it reaches only while the account's is the same. */
74
+ generation: number;
75
+ };
76
+
77
+ /**
78
+ * An MCP connection (T147): the way in of every call its client makes as
79
+ * its person, which a use weighs as it weighs a person's session, a
80
+ * credential of no scope. Its row's other columns, its tokens' hashes and
81
+ * its last use, which a refresh moves, are no part of it.
82
+ */
83
+ export type ConnectionInput = {
84
+ kind: 'connection';
85
+ id: string;
86
+ principal: string;
87
+ /** Its account's generation when its person approved it: it reaches only while the account's is the same. */
88
+ generation: number;
89
+ expiresAt: number;
90
+ revoked: boolean;
91
+ };
92
+
93
+ /**
94
+ * A trust binding (T149, PR 3a; D148): what a sign-in through it decides on,
95
+ * which the vault seals when a manager of its account makes it, and takes
96
+ * no other. Its keys by `jwksUri`: the issuer's discovered URL, or, for keys
97
+ * uploaded with it, `sha256:<hex>` of them, so its seal covers them too.
98
+ * Its claims and policy as their canonical JSON, as the row keeps them.
99
+ */
100
+ export type BindingInput = {
101
+ kind: 'binding';
102
+ id: string;
103
+ principal: string;
104
+ /** Its account's generation when it was made: it signs in nobody of a later one. */
105
+ generation: number;
106
+ profile: string;
107
+ issuer: string;
108
+ /** The instance's public URL it was made on: its base audience (PR 3b). */
109
+ origin: string;
110
+ jwksUri: string;
111
+ claims: string;
112
+ scope: CredentialScope | null;
113
+ policy: string | null;
114
+ revoked: boolean;
115
+ };
116
+
117
+ export type Nature = 'plain' | 'secret' | 'sensitive';
118
+
119
+ export type SecretInput = {
120
+ kind: 'secret';
121
+ id: string;
122
+ projectId: string;
123
+ environmentId: string;
124
+ /** Its current version. */
125
+ version: string;
126
+ nature: Nature;
127
+ /** The version's data key, wrapped; null for a plain value, which the vault never opens, and for a reference. */
128
+ key: WrappedKey | null;
129
+ /**
130
+ * A plain value's text, SHA-256, base64: what binds it, as a key binds a
131
+ * ciphertext (a writer who edits the text leaves a row its seal does not
132
+ * cover). Null for any other.
133
+ */
134
+ digest: string | null;
135
+ /**
136
+ * A reference's source, by its key's id: its value and nature are the
137
+ * source's now (R23). Null for a key with a value, and for a reference
138
+ * that has ended (no version and no source: `ended`).
139
+ */
140
+ source: string | null;
141
+ /**
142
+ * The reference row it reads its source through (`secret_references.id`):
143
+ * what an end decides on, under the seal, never the rows alone, which a
144
+ * writer may rename or plant (formal/SealedObjects.tla, ReferenceInObject).
145
+ * Null when `source` is.
146
+ */
147
+ reference: string | null;
148
+ };
149
+
150
+ export type EnvironmentInput = {
151
+ kind: 'environment';
152
+ id: string;
153
+ projectId: string;
154
+ slug: string;
155
+ /** A personal config's owner (`<env>@me`). */
156
+ owner: string | null;
157
+ /** A fork's or a personal config's parent. */
158
+ parentId: string | null;
159
+ protected: boolean;
160
+ deleted: boolean;
161
+ /** Its own archival. */
162
+ archived: boolean;
163
+ /**
164
+ * Its project's, apart: 0.14 takes it as archived when either is. One
165
+ * bit for both would let a writer clear the environment's own while its
166
+ * project is archived, under a valid seal, for the project's restore to
167
+ * seal in (formal/SealedObjects.tla, ArchivedApart).
168
+ */
169
+ projectArchived: boolean;
170
+ };
171
+
172
+ /**
173
+ * An Owner's edits of the roles, and the roles they made: only what differs
174
+ * from the release's own tables, which a decision merges them over
175
+ * (`readerOf`), as 0.14's `livePolicy` does. A release that changes a role
176
+ * as it ships changes it wherever nobody edited it, and the log, which
177
+ * never held the shipped tables, replays the same under any release.
178
+ */
179
+ export type RolesInput = {
180
+ kind: 'roles';
181
+ roles: Readonly<Record<string, RoleDefinition>>;
182
+ instanceRoles: Readonly<Partial<Record<InstanceRole, RoleDefinition>>>;
183
+ };
184
+
185
+ export type PolicyInput = { kind: 'policy'; bulk: BulkLimit };
186
+
187
+ export type Input = PrincipalInput | CredentialInput | ConnectionInput | BindingInput | SecretInput | EnvironmentInput | RolesInput | PolicyInput;
188
+ export type Kind = Input['kind'];
189
+
190
+ /** An object of the log: its kind and its id (the instance's own for the roles and the policy, of which there is one each). */
191
+ export type Ref = { kind: Kind; id: string };
192
+
193
+ export const ROLES_REF: Ref = { kind: 'roles', id: 'instance' };
194
+ export const POLICY_REF: Ref = { kind: 'policy', id: 'instance' };
195
+
196
+ export function refOf(input: Input): Ref {
197
+ switch (input.kind) {
198
+ case 'principal': return { kind: 'principal', id: input.principal };
199
+ case 'roles': return ROLES_REF;
200
+ case 'policy': return POLICY_REF;
201
+ default: return { kind: input.kind, id: input.id };
202
+ }
203
+ }
204
+
205
+ /**
206
+ * An object's key in the log: the first 16 bytes of SHA-256 over its kind
207
+ * and id, as a uuid. Fixed-width, so a probe of the changes by object is
208
+ * about a fifth cheaper than on the text it names (W211, #366), and the same for every kind of id: a principal is text,
209
+ * the others uuids. Anyone computes it: SQL with `sha256()`.
210
+ */
211
+ export function objectKey(ref: Ref): string {
212
+ return logObject(ref.kind, ref.id);
213
+ }
214
+
215
+ /** `objectKey` for any kind, the log's own included (`log_object` in SQL): a retention change's is ('log', 'retention'). */
216
+ export { logObject };
217
+
218
+ /** The keys seals are made under, derived from the vault's signing keys, the first sealing and every one checking. */
219
+ export type SealKeys = readonly Buffer[];
220
+
221
+ export function sealKey(signingKey: Uint8Array): Buffer {
222
+ return Buffer.from(hkdfSync('sha256', signingKey, new Uint8Array(0), 'coffre.vault.seals.v1', 32));
223
+ }
224
+
225
+ /** A seal's length: HMAC-SHA256 cut to 128 bits (R53), as truncation guidance allows. */
226
+ export const SEAL_BYTES = 16;
227
+
228
+ /**
229
+ * An object's seal: HMAC-SHA256 over the object it is filed under, kind
230
+ * and id, and its canonical encoding, cut to 16 bytes. The id is the
231
+ * key's, not the row's, so a row filed under another object's key is not
232
+ * that object's, however valid its seal is over itself.
233
+ */
234
+ export function sealOf(key: Buffer, ref: Ref, input: Input): Buffer {
235
+ return createHmac('sha256', key).update(`coffre.seal.v1\0${ref.kind}\0${ref.id}\0`).update(canonical(normalized(input))).digest().subarray(0, SEAL_BYTES);
236
+ }
237
+
238
+ /** Whether `seal` is the vault's over `input`, filed under `ref`, by any of its keys. */
239
+ export function sealed(keys: SealKeys, ref: Ref, input: Input, seal: Buffer | null): boolean {
240
+ if (seal === null || seal.length !== SEAL_BYTES) return false;
241
+ return keys.some((key) => timingSafeEqual(sealOf(key, ref, input), seal));
242
+ }
243
+
244
+ /**
245
+ * `sealed`, each answer kept for the calls of one batch of uses (T167), where every key's rows build the shared
246
+ * objects (the way in, the account, the roles, the policy, the places) alike; a key's own, its secret's, as `sealed`: by the object's kind and id, the seal,
247
+ * and the object's JSON as it was built. Equal JSON is an equal object, so an equal canonical encoding and seal: the
248
+ * answer is the one `sealed` gives, at the cost of one `JSON.stringify` where it was the encoding and an HMAC.
249
+ */
250
+ export function sealedOnce(): typeof sealed {
251
+ const kept = new Map<string, boolean>();
252
+ return (keys, ref, input, seal) => {
253
+ // A key's own object (its secret, a reference's source) is no other key's: checked as it is, its JSON not built.
254
+ if (seal === null || ref.kind === 'secret') return sealed(keys, ref, input, seal);
255
+ const at = `${ref.kind}\0${ref.id}\0${seal.toString('hex')}\0${JSON.stringify(input)}`;
256
+ let answer = kept.get(at);
257
+ if (answer === undefined) kept.set(at, (answer = sealed(keys, ref, input, seal)));
258
+ return answer;
259
+ };
260
+ }
261
+
262
+ /**
263
+ * An object as its seal covers it: a principal's grants as a set, in one
264
+ * order whatever order the rows come back in. Everything else is kept as
265
+ * written: a credential's scope is a list it was issued with.
266
+ */
267
+ function normalized(input: Input): Input {
268
+ if (input.kind !== 'principal') return input;
269
+ const grants = input.grants.map((grant) => [grant, canonical(grant)] as const).sort(([, a], [, b]) => (a < b ? -1 : a > b ? 1 : 0));
270
+ return { ...input, grants: grants.map(([grant]) => grant) };
271
+ }
272
+
273
+ /**
274
+ * JSON with every object's keys sorted, and undefined fields left out:
275
+ * one encoding for one value, whichever way its row was read back.
276
+ */
277
+ export function canonical(value: unknown): string {
278
+ return JSON.stringify(value, (_, v: unknown) => (
279
+ v !== null && typeof v === 'object' && !Array.isArray(v)
280
+ ? Object.fromEntries(Object.entries(v).filter(([, x]) => x !== undefined).sort(([a], [b]) => (a < b ? -1 : 1)))
281
+ : v
282
+ ));
283
+ }
284
+
285
+ // --- changes ------------------------------------------------------------------
286
+
287
+ /** Where a grant is, as a revoke names it. */
288
+ export type GrantPlace = Pick<InputGrant, 'projectId' | 'environmentId' | 'environmentSlug'>;
289
+
290
+ /**
291
+ * What a change does to its object, as logged and as the change path
292
+ * applies it (PR 4's `change_input`). `genesis` writes a whole object as a
293
+ * row has it: the cutover's, once per object, and each adoption of a late
294
+ * 0.14 write (plan, PR 9). Every change to the policy carries the whole
295
+ * policy as its `value`, genesis or `bulk.limit`, so a use's statement reads
296
+ * the cap from the newest one at one path (`value.bulk`, schema 0022's
297
+ * `use_credential`: the spec's `CapSealed`).
298
+ */
299
+ export type Op =
300
+ | { op: 'genesis'; value: Input }
301
+ | { op: 'member.add'; role: InstanceRole; scope: Scope }
302
+ | { op: 'member.role'; role: InstanceRole; scope: Scope }
303
+ | { op: 'member.remove' }
304
+ | { op: 'member.restore'; role: InstanceRole; scope: Scope }
305
+ | { op: 'access.grant'; grant: InputGrant }
306
+ | { op: 'access.revoke'; place: GrantPlace }
307
+ | { op: 'credential.issue'; principal: string; caller: CallerKind; scope: CredentialScope | null; expiresAt: number | null; generation: number }
308
+ | { op: 'credential.revoke' }
309
+ /** An MCP connection approved by its person (T147): its whole object. */
310
+ | { op: 'connection.make'; value: ConnectionInput }
311
+ /** An MCP connection ended: disconnected, superseded, its token replayed, or its account removed. */
312
+ | { op: 'connection.revoke' }
313
+ /** A binding made (PR 3a): its whole object, which the vault weighed its maker on. */
314
+ | { op: 'binding.make'; value: BindingInput }
315
+ /** A binding ended: unbound, or replaced by another. */
316
+ | { op: 'binding.revoke' }
317
+ /** A live credential's end moved: a workload's session renewed (never past its hard end, the app's to hold). */
318
+ | { op: 'credential.renew'; expiresAt: number | null }
319
+ /** A live credential's scope written: a token of 0.4's, which carried none, sealed to what its account held (`token.seal`). */
320
+ | { op: 'credential.scope'; scope: CredentialScope | null }
321
+ | { op: 'secret.write'; projectId: string; environmentId: string; version: string; nature: Nature; key: WrappedKey | null; digest: string | null; source: string | null; reference: string | null }
322
+ | { op: 'secret.delete' }
323
+ | { op: 'environment.create'; projectId: string; slug: string; owner: string | null; parentId: string | null; protected: boolean }
324
+ | { op: 'environment.protect'; protected: boolean }
325
+ | { op: 'environment.rename'; slug: string }
326
+ | { op: 'environment.archive'; archived: boolean }
327
+ /** Its project archived or restored: a change of each of the project's environments. */
328
+ | { op: 'project.archive'; archived: boolean }
329
+ | { op: 'environment.delete' }
330
+ | { op: 'role.define'; table: 'roles' | 'instanceRoles'; role: string; definition: RoleDefinition | null }
331
+ | { op: 'bulk.limit'; value: PolicyInput };
332
+
333
+ /** Every object, by its key: the rows a decision reads, as the log has them. */
334
+ export type State = Map<string, Input>;
335
+
336
+ export function lookup<K extends Kind>(state: ReadonlyMap<string, Input>, kind: K, id: string): Extract<Input, { kind: K }> | null {
337
+ const input = state.get(objectKey({ kind, id }));
338
+ return input?.kind === kind ? (input as Extract<Input, { kind: K }>) : null;
339
+ }
340
+
341
+ /**
342
+ * A change applied to its object: what the object is after it (null: gone),
343
+ * or `undefined` if it does nothing, which the change path never logs: a
344
+ * create of something that exists, a change to something that does not, an
345
+ * op of another kind's, or a limit R52 does not allow.
346
+ */
347
+ export function applyOp(before: Input | null, ref: Ref, op: Op): Input | null | undefined {
348
+ // The object, if it is of the kind the change is about: what every change but a create works on.
349
+ const of = <K extends Kind>(kind: K) => (ref.kind === kind && before?.kind === kind ? (before as Extract<Input, { kind: K }>) : null);
350
+ const created = (kind: Kind) => ref.kind === kind && before === null;
351
+ // The roles and the policy are one object each: a change to another of their kind does nothing.
352
+ if ((ref.kind === 'roles' || ref.kind === 'policy') && ref.id !== ROLES_REF.id) return undefined;
353
+ switch (op.op) {
354
+ case 'genesis': {
355
+ // A whole object, held to what the ops that make one hold it to.
356
+ const id = refOf(op.value);
357
+ if (id.kind !== ref.kind || id.id !== ref.id) return undefined;
358
+ if (op.value.kind === 'policy' && !allowedLimit(op.value.bulk)) return undefined;
359
+ if (op.value.kind === 'credential' && !callerFits(op.value)) return undefined;
360
+ // A connection is a person's: a client acts as its person, never as a service.
361
+ if (op.value.kind === 'connection' && !op.value.principal.startsWith('user:')) return undefined;
362
+ return op.value;
363
+ }
364
+ case 'member.add':
365
+ return created('principal') ? { kind: 'principal', principal: ref.id, status: 'active', generation: 0, role: op.role, scope: op.scope, grants: [] } : undefined;
366
+ case 'member.role': {
367
+ const member = of('principal');
368
+ return member?.status === 'active' ? { ...member, role: op.role, scope: op.scope } : undefined;
369
+ }
370
+ case 'member.remove': {
371
+ const member = of('principal');
372
+ return member?.status === 'active' ? { ...member, status: 'removed', generation: member.generation + 1 } : undefined;
373
+ }
374
+ case 'member.restore': {
375
+ const member = of('principal');
376
+ return member?.status === 'removed' ? { ...member, status: 'active', role: op.role, scope: op.scope } : undefined;
377
+ }
378
+ case 'access.grant': {
379
+ const member = of('principal');
380
+ return member === null ? undefined : { ...member, grants: [...member.grants.filter((grant) => !samePlace(grant, op.grant)), op.grant] };
381
+ }
382
+ case 'access.revoke': {
383
+ const member = of('principal');
384
+ if (member === null || !member.grants.some((grant) => samePlace(grant, op.place))) return undefined;
385
+ return { ...member, grants: member.grants.filter((grant) => !samePlace(grant, op.place)) };
386
+ }
387
+ case 'credential.issue':
388
+ return created('credential') && callerFits(op)
389
+ ? { kind: 'credential', id: ref.id, principal: op.principal, caller: op.caller, scope: op.scope, expiresAt: op.expiresAt, revoked: false, generation: op.generation }
390
+ : undefined;
391
+ case 'credential.revoke': {
392
+ const credential = of('credential');
393
+ return credential !== null && !credential.revoked ? { ...credential, revoked: true } : undefined;
394
+ }
395
+ case 'connection.make':
396
+ return created('connection') && op.value.kind === 'connection' && op.value.id === ref.id && !op.value.revoked && op.value.principal.startsWith('user:') ? op.value : undefined;
397
+ case 'connection.revoke': {
398
+ const connection = of('connection');
399
+ return connection !== null && !connection.revoked ? { ...connection, revoked: true } : undefined;
400
+ }
401
+ case 'binding.make':
402
+ return created('binding') && op.value.id === ref.id && !op.value.revoked ? op.value : undefined;
403
+ case 'binding.revoke': {
404
+ const binding = of('binding');
405
+ return binding !== null && !binding.revoked ? { ...binding, revoked: true } : undefined;
406
+ }
407
+ case 'credential.renew': {
408
+ const credential = of('credential');
409
+ return credential !== null && !credential.revoked ? { ...credential, expiresAt: op.expiresAt } : undefined;
410
+ }
411
+ case 'credential.scope': {
412
+ const credential = of('credential');
413
+ const scoped = credential === null || credential.revoked ? null : { ...credential, scope: op.scope };
414
+ return scoped !== null && callerFits(scoped) ? scoped : undefined;
415
+ }
416
+ case 'secret.write': {
417
+ const secret = of('secret');
418
+ if (!created('secret') && secret === null) return undefined;
419
+ // A key stays where it was made: a write elsewhere is another key.
420
+ if (secret !== null && (secret.projectId !== op.projectId || secret.environmentId !== op.environmentId)) return undefined;
421
+ const { projectId, environmentId, version, nature, key, digest, source, reference } = op;
422
+ return { kind: 'secret', id: ref.id, projectId, environmentId, version, nature, key, digest, source, reference };
423
+ }
424
+ case 'secret.delete':
425
+ return of('secret') === null ? undefined : null;
426
+ case 'environment.create':
427
+ return created('environment')
428
+ ? { kind: 'environment', id: ref.id, projectId: op.projectId, slug: op.slug, owner: op.owner, parentId: op.parentId, protected: op.protected, deleted: false, archived: false, projectArchived: false }
429
+ : undefined;
430
+ case 'environment.protect': case 'environment.rename': case 'environment.archive': case 'project.archive': case 'environment.delete': {
431
+ const environment = of('environment');
432
+ if (environment === null || environment.deleted) return undefined;
433
+ if (op.op === 'environment.protect') return { ...environment, protected: op.protected };
434
+ // A scoped role's filters read the slug: a rename is a change like any other (0.14's `environment.update`).
435
+ if (op.op === 'environment.rename') return { ...environment, slug: op.slug };
436
+ if (op.op === 'project.archive') return { ...environment, projectArchived: op.archived };
437
+ // Deleted, it holds no project's archival (objects.ts).
438
+ return op.op === 'environment.archive' ? { ...environment, archived: op.archived } : { ...environment, deleted: true, projectArchived: false };
439
+ }
440
+ case 'role.define': {
441
+ const edits = of('roles') ?? (created('roles') ? noEdits() : null);
442
+ if (edits === null) return undefined;
443
+ // A table with no prototype: a role named `__proto__` is a role like another, never the object's prototype.
444
+ const table: Record<string, RoleDefinition> = Object.assign(Object.create(null) as Record<string, RoleDefinition>, edits[op.table]);
445
+ const shipped: Readonly<Record<string, RoleDefinition>> = op.table === 'roles' ? ROLES : INSTANCE_ROLES;
446
+ const same = op.definition !== null && Object.hasOwn(shipped, op.role) && canonical(normalDefinition(shipped[op.role]!)) === canonical(normalDefinition(op.definition));
447
+ // No definition, or the one it ships with: no edit, so the release's own decides.
448
+ if (op.definition === null || same) delete table[op.role];
449
+ else table[op.role] = op.definition;
450
+ return { ...edits, [op.table]: { ...table } };
451
+ }
452
+ case 'bulk.limit':
453
+ return (created('policy') || of('policy') !== null) && op.value.kind === 'policy' && allowedLimit(op.value.bulk)
454
+ ? { kind: 'policy', bulk: { count: op.value.bulk.count, windowMs: op.value.bulk.windowMs } }
455
+ : undefined;
456
+ }
457
+ }
458
+
459
+ /** No edits: the roles object of a log no Owner edited a role in. */
460
+ export function noEdits(): RolesInput {
461
+ return { kind: 'roles', roles: {}, instanceRoles: {} };
462
+ }
463
+
464
+ /** A cap R52 allows: a whole count of at least one, a window above nothing and at most a day (`checkBulkLimit` in config.ts). */
465
+ function allowedLimit({ count, windowMs }: BulkLimit): boolean {
466
+ return Number.isSafeInteger(count) && count >= 1 && Number.isSafeInteger(windowMs) && windowMs > 0 && windowMs <= MAX_BULK_WINDOW_MINUTES * 60_000;
467
+ }
468
+
469
+ /**
470
+ * A credential its principal can hold (`through` in vault.ts): a person's
471
+ * for a `user:`, a bearer's or a workload's for a `token:`, and a
472
+ * workload's with the scope that lists what it reads.
473
+ */
474
+ function callerFits({ principal, caller, scope }: { principal: string; caller: CallerKind; scope: CredentialScope | null }): boolean {
475
+ if (caller === 'person') return principal.startsWith('user:');
476
+ return principal.startsWith('token:') && (caller === 'bearer' || scope !== null);
477
+ }
478
+
479
+ function samePlace(a: GrantPlace, b: GrantPlace): boolean {
480
+ return a.projectId === b.projectId && a.environmentId === b.environmentId && a.environmentSlug === b.environmentSlug;
481
+ }
482
+
483
+ /**
484
+ * The change a vault starting with `configured` writes before it serves a
485
+ * use, if any: the cap is the deployment's code, while decisions, live and
486
+ * replayed, read the log's, so the two meet here (plan, PR 3). Null when
487
+ * the log already says the same.
488
+ */
489
+ export function limitChange(policy: PolicyInput | null, configured: BulkLimit): Op | null {
490
+ // One no change would apply (a window of a fraction of a millisecond) must stop the start, not leave the log policy-less.
491
+ if (!allowedLimit(configured)) throw new Error(`bulkLimit must be a whole count of at least 1 and a whole number of milliseconds up to a day, got ${JSON.stringify(configured)}`);
492
+ if (policy !== null && policy.bulk.count === configured.count && policy.bulk.windowMs === configured.windowMs) return null;
493
+ return limitOp(configured);
494
+ }
495
+
496
+ /** The change that sets the bulk cap: the whole policy, as its `value`. */
497
+ export function limitOp(bulk: BulkLimit): Op {
498
+ return { op: 'bulk.limit', value: { kind: 'policy', bulk: { count: bulk.count, windowMs: bulk.windowMs } } };
499
+ }
package/src/local.ts CHANGED
@@ -23,12 +23,27 @@ export const METHODS = [
23
23
  'about',
24
24
  'keyChecks',
25
25
  'verifyLog',
26
+ 'use',
27
+ 'useNamed',
28
+ 'useMany',
29
+ 'prepare',
30
+ 'waitSealed',
31
+ 'signIn',
32
+ 'revokeCredential',
33
+ 'issueToken',
34
+ 'changeBindings',
35
+ 'cutover',
36
+ 'verifyTrees',
37
+ 'verifyReads',
38
+ 'vouch',
39
+ 'sealing',
26
40
  ] as const satisfies readonly (keyof Vault)[];
27
41
 
28
- export type LocalVault = Vault & { close(): Promise<void> };
42
+ export type LocalVault = Vault & { use: NonNullable<Vault['use']>; close(): Promise<void> };
29
43
 
30
44
  /**
31
- * The vault in this process, over `db`. Every argument and result goes
45
+ * The vault in this process, over `db`, and `hot` for 0.15's uses (vault.ts,
46
+ * `openVault`). Every argument and result goes
32
47
  * through JSON on the way, as it would over RPC or a socket, so what works
33
48
  * here does not rely on sharing objects with the caller. `close` is
34
49
  * whatever lets go of the database, when the vault opened it.
@@ -38,15 +53,16 @@ export async function openLocalVault(
38
53
  config: ResolvedVaultConfig,
39
54
  options: VaultOptions = {},
40
55
  close: () => Promise<void> = async () => {},
56
+ hot?: Database,
41
57
  ): Promise<LocalVault> {
42
- const vault = openVault(db, await prepareVault(config, options));
58
+ const vault = openVault(db, await prepareVault(config, options), hot);
43
59
  const local = Object.fromEntries(
44
60
  METHODS.map((name) => [
45
61
  name,
46
62
  async (...args: unknown[]) =>
47
63
  json(await (vault[name] as (...args: unknown[]) => Promise<unknown>)(...(json(args) as unknown[]))),
48
64
  ]),
49
- ) as unknown as Vault;
65
+ ) as unknown as Omit<LocalVault, 'close'>;
50
66
  return Object.assign(local, { close });
51
67
  }
52
68
 
@@ -0,0 +1,116 @@
1
+ import { pairSigners } from '@coffre/core/merkle';
2
+
3
+ import { Checkpointer, FenceClosed, logOrigin, type Alarm, type CheckpointStore, type Fence, type Memory, type Witness } from './checkpointer.ts';
4
+ import { authentic, logMacKey } from './log-entry.ts';
5
+ import type { LogStore } from './log-store.ts';
6
+ import { DAY_MS, dayOf, retain } from './retention.ts';
7
+
8
+ /**
9
+ * The checkpointer as a host runs it, on Node (`runCheckpointer`) or in its
10
+ * Durable Object (`CheckpointerObject`): the trees' keys and the MAC key
11
+ * from the vault's signing seed, the log's origin from that key, and every
12
+ * alarm handed to `record` as well as returned by `alarms`, where whoever
13
+ * watches the log reads them (R40's plugin): refused ticks, changes answered
14
+ * unwitnessed (R51), a memory found behind the database.
15
+ */
16
+ export type HostOptions = {
17
+ store: CheckpointStore;
18
+ /** The vault's signing seed: the first of `signingKeys`. */
19
+ seed: Uint8Array;
20
+ memory: Memory;
21
+ witnesses?: readonly Witness[];
22
+ /** Where each alarm is kept; it is also logged. */
23
+ record?: (alarm: Alarm) => void | Promise<void>;
24
+ now?: () => number;
25
+ /** What closes it for good, when its host bounds the open (T169); `TICK_BOUND_MS` otherwise, for the tests. */
26
+ fence?: Fence;
27
+ tickBoundMs?: number;
28
+ };
29
+
30
+ export async function openLogCheckpointer(options: HostOptions): Promise<Checkpointer> {
31
+ const signers = await pairSigners(await logOrigin(options.seed), options.seed);
32
+ const now = options.now ?? Date.now;
33
+ return Checkpointer.open(options.store, signers, {
34
+ memory: options.memory,
35
+ witnesses: options.witnesses,
36
+ authentic: authentic(logMacKey(options.seed)),
37
+ now,
38
+ ...(options.fence === undefined ? {} : { fence: options.fence }),
39
+ ...(options.tickBoundMs === undefined ? {} : { tickBoundMs: options.tickBoundMs }),
40
+ alert: (message) => {
41
+ console.warn(`coffre audit: ${message}`);
42
+ void Promise.resolve(options.record?.({ at: now(), message })).catch((error: Error) => console.warn(`coffre audit: the alarm could not be kept: ${error.message}`));
43
+ },
44
+ });
45
+ }
46
+
47
+ /** How often the checkpointer seals (R51, R54): a change waits at most about this long for a tick, and seals sooner if it asks. */
48
+ export const TICK_MS = 500;
49
+
50
+ /** An error as an alarm says it: the database's own reason, when a query wrapped it, rather than the query's text. */
51
+ export function reasonOf(error: unknown): string {
52
+ const e = error as Error & { cause?: unknown };
53
+ return e?.cause instanceof Error ? e.cause.message : (e?.message ?? String(error));
54
+ }
55
+
56
+ /** Days of uses made ahead of today's (and yesterday's, for a new database): uses stamped in a day with no partition fail. */
57
+ export const DAYS_AHEAD = 3;
58
+ /** How often retention runs: hourly, so that a day goes within the hour it is due, and a run that waits tries again. */
59
+ export const RETAIN_EVERY_MS = 3_600_000;
60
+ /** How often the bulk cap's old rows are pruned, at most `PRUNED` a time (R52's window, in `prune_bulk`). */
61
+ export const PRUNE_EVERY_MS = 60_000;
62
+ const PRUNED = 10_000;
63
+
64
+ /** How long retention may wait on one lock (a reader holding the uses, say) before that is an alarm. */
65
+ export const WAITING_ALARM_MS = DAY_MS;
66
+
67
+ /** The days of uses a host makes ahead at `t`: yesterday's (a new database), today's, and `DAYS_AHEAD` more. */
68
+ export const daysAround = (t: number) => Array.from({ length: DAYS_AHEAD + 2 }, (_, i) => dayOf(t + (i - 1) * DAY_MS));
69
+
70
+ /**
71
+ * What a host runs after each tick (plan PR 7): the next days' partitions
72
+ * made once a day, retention once an hour when `keepDays` is set, the bulk
73
+ * cap's old rows pruned once a minute. A failure is an alarm, said once while
74
+ * it stands; retention waiting is none, but for a lock: one that has kept a
75
+ * day waiting longer than `WAITING_ALARM_MS`. A quiet log, whose pairs do not
76
+ * pass a day's end until something is logged, waits with no alarm.
77
+ */
78
+ export function upkeep(cp: Checkpointer, store: LogStore, options: { keepDays: number | null; seed: Uint8Array; now?: () => number; alert: (message: string) => void }): () => Promise<void> {
79
+ const now = options.now ?? Date.now;
80
+ const macKey = logMacKey(options.seed);
81
+ let madeFor: string | null = null;
82
+ let retained = -Infinity;
83
+ let pruned = -Infinity;
84
+ let failed: string | null = null;
85
+ // The day retention waits on, and since when.
86
+ let waiting: { day: string; since: number } | null = null;
87
+ return async () => {
88
+ const t = now();
89
+ try {
90
+ if (madeFor !== dayOf(t)) {
91
+ await store.makeDays(daysAround(t));
92
+ madeFor = dayOf(t);
93
+ }
94
+ if (t - pruned >= PRUNE_EVERY_MS) {
95
+ pruned = t;
96
+ await store.pruneBulk(PRUNED);
97
+ }
98
+ if (options.keepDays !== null && t - retained >= RETAIN_EVERY_MS) {
99
+ retained = t;
100
+ const run = await retain(cp, store, { keepDays: options.keepDays, macKey, now });
101
+ const day = run.waiting?.lock ? (run.waiting.day ?? 'the tiles') : null;
102
+ if (day === null) waiting = null;
103
+ else if (waiting?.day !== day) waiting = { day, since: t };
104
+ else if (t - waiting.since >= WAITING_ALARM_MS) throw new Error(`retention has waited on ${day} since ${new Date(waiting.since).toISOString()}: ${run.waiting!.reason}`);
105
+ }
106
+ failed = null;
107
+ } catch (error) {
108
+ // Its checkpointer closed under it, given up on or replaced: the next one's upkeep does the work, and this is no
109
+ // alarm (review by Fable).
110
+ if (error instanceof FenceClosed || (error as { cause?: unknown })?.cause instanceof FenceClosed) return;
111
+ const message = `the log's upkeep failed: ${reasonOf(error)}`;
112
+ if (message !== failed) options.alert(message);
113
+ failed = message;
114
+ }
115
+ };
116
+ }