@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.
- package/README.md +6 -4
- package/dist/cloudflare.d.ts +343 -9
- package/dist/cloudflare.js +802 -6
- package/dist/{index-CIq4i_KT.d.ts → index-DQ-FKVF3.d.ts} +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/log-entry-BYWqj3O5.d.ts +364 -0
- package/dist/node.d.ts +182 -7
- package/dist/node.js +358 -16
- package/dist/{src-NM5NsYFs.js → src-DheG92EM.js} +9 -2
- package/dist/vault-kmtojYnW.js +14623 -0
- package/package.json +4 -5
- package/src/bulk.ts +89 -0
- package/src/changes.ts +478 -0
- package/src/checkpointer-node.ts +342 -0
- package/src/checkpointer-workers.ts +514 -0
- package/src/checkpointer.ts +1165 -0
- package/src/cloudflare.ts +207 -13
- package/src/config.ts +15 -3
- package/src/credentials.postgres.ts +78 -0
- package/src/credentials.sqlite.ts +240 -0
- package/src/credentials.ts +630 -0
- package/src/cutover.ts +275 -0
- package/src/decide.ts +437 -2
- package/src/inputs.ts +499 -0
- package/src/local.ts +20 -4
- package/src/log-checkpointer.ts +116 -0
- package/src/log-entry.ts +113 -0
- package/src/log-replay.ts +188 -0
- package/src/log-store.ts +695 -0
- package/src/node.ts +73 -9
- package/src/objects.ts +141 -0
- package/src/reads.ts +502 -0
- package/src/retention.ts +229 -0
- package/src/snapshot.postgres.ts +39 -9
- package/src/snapshot.sqlite.ts +28 -3
- package/src/snapshot.ts +72 -12
- package/src/store.ts +862 -37
- package/src/use.postgres.ts +111 -0
- package/src/use.sqlite.ts +542 -0
- package/src/use.ts +1060 -0
- package/src/vault.ts +1513 -240
- package/src/verifier.ts +136 -0
- package/src/witness-workers.ts +163 -0
- 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
|
|
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
|
|
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
|
+
}
|