@coffre/vault 0.14.5 → 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 +341 -4
- package/dist/cloudflare.js +802 -6
- package/dist/index.js +1 -1
- package/dist/log-entry-BYWqj3O5.d.ts +364 -0
- package/dist/node.d.ts +181 -6
- package/dist/node.js +358 -16
- package/dist/{src-Bg0ZeMJW.js → src-DheG92EM.js} +1 -1
- 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 +3 -2
- 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 +64 -10
- package/src/store.ts +832 -36
- package/src/use.postgres.ts +111 -0
- package/src/use.sqlite.ts +542 -0
- package/src/use.ts +1060 -0
- package/src/vault.ts +1366 -220
- package/src/verifier.ts +136 -0
- package/src/witness-workers.ts +163 -0
- package/dist/vault-Cbi45kLB.js +0 -5197
package/src/cutover.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { isCredentialScope } from '@coffre/core/access';
|
|
4
|
+
import { deletedWithProject, isTombstone, TOMBSTONE } from '@coffre/core/schemas';
|
|
5
|
+
import type { CutoverInput, PolicyEdits } from '@coffre/core/vault';
|
|
6
|
+
import type { GrantRow } from '@coffre/db/grants';
|
|
7
|
+
|
|
8
|
+
import { genesisCall, payloadOf, type ChangeKeys } from './changes.ts';
|
|
9
|
+
import type { BulkLimit } from './config.ts';
|
|
10
|
+
import { canonical, objectKey, sealOf, type Input, type Ref, type RolesInput } from './inputs.ts';
|
|
11
|
+
import { CUTOVER_ACTOR, CUTOVER_OBJECT, entryMac } from './log-entry.ts';
|
|
12
|
+
import { bindingObject, connectionObject, credentialObject, environmentObject, principalObject, secretObject } from './objects.ts';
|
|
13
|
+
import type { BindingRead, ChangeCall, ConnectionRead, CredentialRead, EnvironmentRead, Member, ProjectRow, SecretRead } from './store.ts';
|
|
14
|
+
|
|
15
|
+
export { CUTOVER_ACTOR, CUTOVER_OBJECT };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 0.15's cutover from 0.14 (docs/design/0.15-plan.md, PR 9;
|
|
19
|
+
* formal/Migration015.tla): the rows 0.14 left, read and held to what 0.14
|
|
20
|
+
* accepts, and the log's first changes made of them. The vault does it once,
|
|
21
|
+
* at its first start with 0.15's log, when the app asks (`Vault.cutover`),
|
|
22
|
+
* under every member's row, 0.14's head, the issue lock and the fence; this
|
|
23
|
+
* module is its reads and writes, and its plan, pure, of what to seal.
|
|
24
|
+
*
|
|
25
|
+
* Each object 0.15's log has no change of, and whose row carries no seal:
|
|
26
|
+
* - **a member** (principal), if 0.14's own check holds (`#integrity`: its
|
|
27
|
+
* MAC, and it names its newest authentic access entry); its genesis names
|
|
28
|
+
* that entry (`old`), which a use holds 0.14's log to (UsesSeeOldWrites);
|
|
29
|
+
* - **the roles**, from 0.14's newest authentic `policy.change`, and the
|
|
30
|
+
* **policy**, the deployment's bulk cap;
|
|
31
|
+
* - **an environment**, its protection from that policy; deleted as 0.14's
|
|
32
|
+
* chain says, never its slugs (DeletedFromChain): a project's
|
|
33
|
+
* `project.delete`, the environment's own `environment.delete`, each
|
|
34
|
+
* checked by the app under its key. A tombstone with no entry, or an entry
|
|
35
|
+
* with no tombstone, refuses the cutover. A deleted project's standing
|
|
36
|
+
* environments are given `<slug>~deleted` (`deletedWithProject`), as 0.15
|
|
37
|
+
* deletes a project;
|
|
38
|
+
* - **a key**, as its rows hold it, unless its place was deleted: then it is
|
|
39
|
+
* archived, if it was not, and has no object, as 0.15's deletion leaves it.
|
|
40
|
+
* An archived key gets its object and its `secret.delete` (T144), so its
|
|
41
|
+
* unarchive has something to restore. A reference row no authentic
|
|
42
|
+
* `reference.create` made is no object; one an authentic `reference.end`
|
|
43
|
+
* ended has its row's end written first (0029);
|
|
44
|
+
* - **a credential**, **a binding** or **an MCP connection** the app
|
|
45
|
+
* vouched for under its key (`CutoverInput`), as it read it
|
|
46
|
+
* (VouchedHeld): its row must still be that, or the cutover asks the app
|
|
47
|
+
* again (`Moved`).
|
|
48
|
+
*
|
|
49
|
+
* A row 0.14 would not accept gets no genesis: its object stays unsealed,
|
|
50
|
+
* every use of it `stale`, never sealed as a writer left it.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/** Why the cutover asks the app again: a 0.14 write since what it vouched for was read. */
|
|
54
|
+
export class Moved extends Error {}
|
|
55
|
+
|
|
56
|
+
/** Why a cutover is refused: 0.14's chain or rows not as 0.14 accepts them. Nothing is sealed. */
|
|
57
|
+
export class CutoverRefused extends Error {}
|
|
58
|
+
|
|
59
|
+
/** The cutover's change: the head of 0.14's it names (its own `log.cutover` entry), and where its chain was verified from. */
|
|
60
|
+
export function cutoverPayload(anchor: { seq: bigint; hash: Buffer }, from: { seq: bigint; hash: Buffer }): string {
|
|
61
|
+
return canonical({
|
|
62
|
+
op: 'cutover',
|
|
63
|
+
anchor: { seq: anchor.seq.toString(), hash: anchor.hash.toString('hex') },
|
|
64
|
+
from: { seq: from.seq.toString(), hash: from.hash.toString('hex') },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The cutover's change, MAC'd as any entry of the changes tree. */
|
|
69
|
+
export function cutoverRow(keys: ChangeKeys, at: number, payload: string): { requestId: string; at: number; actor: string; payload: string; mac: Buffer } {
|
|
70
|
+
const row = { at, requestId: randomUUID(), object: CUTOVER_OBJECT, actor: CUTOVER_ACTOR, payload, seal: null };
|
|
71
|
+
return { requestId: row.requestId, at, actor: CUTOVER_ACTOR, payload, mac: entryMac(keys.mac, { tree: 'changes', row }) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- what it seals ------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** What 0.14 holds that the cutover seals, read under its locks, and what was checked of it. */
|
|
77
|
+
export type CutoverFacts = {
|
|
78
|
+
/** The objects the log has a change of: 0.15's already, never sealed again. */
|
|
79
|
+
logged: ReadonlySet<string>;
|
|
80
|
+
/** The rows of `log_config` there are, by name: the roles' and the policy's, which only a change writes. */
|
|
81
|
+
config: ReadonlySet<string>;
|
|
82
|
+
/** Each member, with their grants, their row's seal, and whether 0.14's own check of them holds (`#integrity`). */
|
|
83
|
+
members: readonly { member: Member; grants: readonly GrantRow[]; seal: Buffer | null; genuine: boolean }[];
|
|
84
|
+
/** The roles an Owner edited, and the environments protected, as 0.14's newest authentic `policy.change` says, and its number. */
|
|
85
|
+
edits: PolicyEdits;
|
|
86
|
+
policySeq: bigint | null;
|
|
87
|
+
/** The deployment's bulk cap. */
|
|
88
|
+
bulk: BulkLimit;
|
|
89
|
+
projects: readonly ProjectRow[];
|
|
90
|
+
environments: readonly EnvironmentRead[];
|
|
91
|
+
secrets: readonly SecretRead[];
|
|
92
|
+
/** Each reference row's `reference.create`, by that entry's number: whether it is the vault's, about that row, and whether an authentic `reference.end` ended it. */
|
|
93
|
+
references: ReadonlyMap<string, { authentic: boolean; ended: boolean }>;
|
|
94
|
+
/** What the app vouched for. */
|
|
95
|
+
input: CutoverInput;
|
|
96
|
+
credentials: ReadonlyMap<string, CredentialRead>;
|
|
97
|
+
bindings: ReadonlyMap<string, BindingRead>;
|
|
98
|
+
connections: ReadonlyMap<string, ConnectionRead>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** One object's genesis: its object, the 0.14 entry it accounts for, and, for an archived key, its delete after. */
|
|
102
|
+
export type Genesis = { ref: Ref; object: Input; old?: bigint; deleted?: true };
|
|
103
|
+
|
|
104
|
+
export type CutoverPlan = {
|
|
105
|
+
/** Each standing environment of a deleted project, to be given `<slug>~deleted`, and each live key under a deleted place, to be archived. */
|
|
106
|
+
rename: string[];
|
|
107
|
+
archive: string[];
|
|
108
|
+
/** Reference rows an authentic `reference.end` ended, to be marked so before their holders' genesis. */
|
|
109
|
+
ends: string[];
|
|
110
|
+
genesis: Genesis[];
|
|
111
|
+
bindings: Genesis[];
|
|
112
|
+
/** Objects left unsealed, and why: 0.14 would not accept them as they are (`stale`), or no one but a writer made them (`tampered`). */
|
|
113
|
+
skipped: { ref: Ref; why: string; fault: 'stale' | 'tampered' }[];
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* What the cutover seals, from `facts`: pure. Throws `CutoverRefused` for
|
|
118
|
+
* a place whose deletion its chain and its slugs disagree about, and `Moved`
|
|
119
|
+
* for a row the app vouched for that is not as it read it.
|
|
120
|
+
*/
|
|
121
|
+
export function planCutover(facts: CutoverFacts): CutoverPlan {
|
|
122
|
+
const plan: CutoverPlan = { rename: [], archive: [], ends: [], genesis: [], bindings: [], skipped: [] };
|
|
123
|
+
const fresh = (ref: Ref, seal: Buffer | null) => seal === null && !facts.logged.has(objectKey(ref));
|
|
124
|
+
|
|
125
|
+
for (const { member, grants, seal, genuine } of facts.members) {
|
|
126
|
+
const ref: Ref = { kind: 'principal', id: member.principal };
|
|
127
|
+
if (!fresh(ref, seal)) continue;
|
|
128
|
+
if (!genuine) {
|
|
129
|
+
plan.skipped.push({ ref, why: "0.14's check of their row fails (#integrity)", fault: 'stale' });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
plan.genesis.push({ ref, object: principalObject(member, grants), old: member.accessSeq });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The roles and the policy, made from 0.14's policy and the deployment's cap: a row of either no change made was planted.
|
|
136
|
+
const policySeq = facts.policySeq ?? undefined;
|
|
137
|
+
const config = (ref: Ref, object: Input, old?: bigint) => {
|
|
138
|
+
if (facts.logged.has(objectKey(ref))) return;
|
|
139
|
+
if (facts.config.has(ref.kind)) plan.skipped.push({ ref, why: 'its row no change made', fault: 'tampered' });
|
|
140
|
+
else plan.genesis.push({ ref, object, ...(old === undefined ? {} : { old }) });
|
|
141
|
+
};
|
|
142
|
+
config({ kind: 'roles', id: 'instance' }, { kind: 'roles', roles: { ...facts.edits.roles }, instanceRoles: { ...facts.edits.instanceRoles } } satisfies RolesInput, policySeq);
|
|
143
|
+
config({ kind: 'policy', id: 'instance' }, { kind: 'policy', bulk: { count: facts.bulk.count, windowMs: facts.bulk.windowMs } });
|
|
144
|
+
|
|
145
|
+
// Deleted as 0.14's chain says (DeletedFromChain): each tombstone the chain's, each of the chain's deletions a tombstone.
|
|
146
|
+
const deletedProjects = new Map(facts.input.deleted.filter((place) => place.kind === 'project').map((place) => [place.id, place.tombstone]));
|
|
147
|
+
const deletedAlone = new Map(facts.input.deleted.filter((place) => place.kind === 'environment').map((place) => [place.id, place.tombstone]));
|
|
148
|
+
const projects = new Map(facts.projects.map((project) => [project.id, project]));
|
|
149
|
+
for (const project of facts.projects) {
|
|
150
|
+
const tombstone = deletedProjects.get(project.id);
|
|
151
|
+
if (tombstone === undefined ? isTombstone(project.slug) : project.slug !== tombstone) {
|
|
152
|
+
throw new CutoverRefused(`the project ${project.id}'s slug, ${project.slug}, is ${tombstone === undefined ? 'a tombstone no authentic project.delete gave it' : `not the tombstone its project.delete gave it, ${tombstone}`}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const deletedPlace = new Map<string, boolean>();
|
|
156
|
+
for (const environment of facts.environments) {
|
|
157
|
+
const ownTombstone = deletedAlone.get(environment.id);
|
|
158
|
+
const withProject = deletedProjects.has(environment.projectId);
|
|
159
|
+
if (ownTombstone !== undefined ? environment.slug !== ownTombstone : isTombstone(environment.slug) && !(withProject && environment.slug === deletedWithProject(baseSlug(environment.slug)))) {
|
|
160
|
+
throw new CutoverRefused(`the environment ${environment.id}'s slug, ${environment.slug}, is ${ownTombstone === undefined ? 'a tombstone no authentic deletion gave it' : `not the tombstone its environment.delete gave it, ${ownTombstone}`}`);
|
|
161
|
+
}
|
|
162
|
+
deletedPlace.set(environment.id, ownTombstone !== undefined || withProject);
|
|
163
|
+
const ref: Ref = { kind: 'environment', id: environment.id };
|
|
164
|
+
if (!fresh(ref, environment.seal)) continue;
|
|
165
|
+
let { slug } = environment;
|
|
166
|
+
if (withProject && !isTombstone(slug)) {
|
|
167
|
+
plan.rename.push(environment.id);
|
|
168
|
+
slug = deletedWithProject(slug);
|
|
169
|
+
}
|
|
170
|
+
const object = environmentObject({
|
|
171
|
+
id: environment.id, projectId: environment.projectId, slug, owner: environment.owner, parentId: environment.parentId,
|
|
172
|
+
protected: facts.edits.protected.some((place) => place.environmentId === environment.id), archived: environment.archived,
|
|
173
|
+
projectArchived: projects.get(environment.projectId)?.archived ?? false,
|
|
174
|
+
});
|
|
175
|
+
plan.genesis.push({ ref, object, ...(policySeq === undefined ? {} : { old: policySeq }) });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const secret of facts.secrets) {
|
|
179
|
+
const ref: Ref = { kind: 'secret', id: secret.id };
|
|
180
|
+
if (!fresh(ref, secret.seal)) continue;
|
|
181
|
+
// Under a place deleted: no object, as 0.15's deletion leaves its keys (archived, their versions erased).
|
|
182
|
+
if (deletedPlace.get(secret.environmentId) !== false) {
|
|
183
|
+
if (!secret.archived && deletedPlace.get(secret.environmentId) === true) plan.archive.push(secret.id);
|
|
184
|
+
if (deletedPlace.get(secret.environmentId) === undefined) plan.skipped.push({ ref, why: 'its environment has no row', fault: 'stale' });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
let reads: { source: string | null; reference: string | null } = { source: null, reference: null };
|
|
188
|
+
if (secret.version === null && secret.reference !== null) {
|
|
189
|
+
const made = facts.references.get(secret.reference.createdSeq.toString());
|
|
190
|
+
if (made?.authentic !== true) {
|
|
191
|
+
plan.skipped.push({ ref, why: `its reference row ${secret.reference.id} is no authentic reference.create's`, fault: 'tampered' });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// Its row says it ended where 0.14's log has no end of it: 0.14 still reads through it (review r6). Not sealed.
|
|
195
|
+
if (secret.reference.ended && !made.ended) {
|
|
196
|
+
plan.skipped.push({ ref, why: `its reference row ${secret.reference.id} says it ended, which no reference.end does`, fault: 'tampered' });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// Ended by an authentic `reference.end`: its row says so first (0029's `end_reference`), and the key reads nothing.
|
|
200
|
+
if (made.ended && !secret.reference.ended) plan.ends.push(secret.reference.id);
|
|
201
|
+
if (!made.ended && !secret.reference.ended) reads = { source: secret.reference.source, reference: secret.reference.id };
|
|
202
|
+
}
|
|
203
|
+
// Its current version not its newest: rolled back around the app, which 0.14 refuses to read through a reference.
|
|
204
|
+
if (!secret.newest) {
|
|
205
|
+
plan.skipped.push({ ref, why: 'its current version is not its newest', fault: 'stale' });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const object = secretObject({ id: secret.id, projectId: secret.projectId, environmentId: secret.environmentId, ...reads, version: secret.version });
|
|
209
|
+
plan.genesis.push(secret.archived ? { ref, object, deleted: true } : { ref, object });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// What the app vouched for, held to its rows as it read them (VouchedHeld).
|
|
213
|
+
for (const vouched of facts.input.credentials) {
|
|
214
|
+
const ref: Ref = { kind: 'credential', id: vouched.id };
|
|
215
|
+
const read = facts.credentials.get(vouched.id);
|
|
216
|
+
if (read === undefined) throw new Moved(`the credential ${vouched.id} the app vouched for has no row now`);
|
|
217
|
+
if (read.logged || read.seal !== null || facts.logged.has(objectKey(ref))) continue;
|
|
218
|
+
if (canonical(read.columns) !== canonical(vouched)) throw new Moved(`the credential ${vouched.id}'s row moved since the app vouched for it`);
|
|
219
|
+
plan.genesis.push({ ref, object: credentialObject(read.columns) });
|
|
220
|
+
}
|
|
221
|
+
for (const vouched of facts.input.connections) {
|
|
222
|
+
const ref: Ref = { kind: 'connection', id: vouched.id };
|
|
223
|
+
const read = facts.connections.get(vouched.id);
|
|
224
|
+
if (read === undefined) throw new Moved(`the connection ${vouched.id} the app vouched for has no row now`);
|
|
225
|
+
if (read.logged || read.seal !== null || facts.logged.has(objectKey(ref))) continue;
|
|
226
|
+
if (canonical(read.columns) !== canonical(vouched) || read.columns.revoked) throw new Moved(`the connection ${vouched.id}'s row moved since the app vouched for it`);
|
|
227
|
+
plan.genesis.push({ ref, object: connectionObject(read.columns) });
|
|
228
|
+
}
|
|
229
|
+
for (const vouched of facts.input.bindings) {
|
|
230
|
+
const ref: Ref = { kind: 'binding', id: vouched.id };
|
|
231
|
+
const read = facts.bindings.get(vouched.id);
|
|
232
|
+
if (read === undefined) throw new Moved(`the binding ${vouched.id} the app vouched for has no row now`);
|
|
233
|
+
if (read.logged || read.seal !== null || facts.logged.has(objectKey(ref))) continue;
|
|
234
|
+
// The instance it was made on: the deployment's public URL, as the app vouched for it (T160), which its genesis writes
|
|
235
|
+
// beside its seal. 0.14 writes none, and its MAC covers none: a row with one is not 0.14's.
|
|
236
|
+
const { origin: _, ...columns } = vouched;
|
|
237
|
+
if (read.origin !== null) {
|
|
238
|
+
plan.skipped.push({ ref, why: 'it names an instance, which no binding of 0.14 does', fault: 'tampered' });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
// A scope 0.14 never writes (`seal_binding` holds the row's to its object's, as JSON: one that is no scope would not be).
|
|
242
|
+
if (!scopeReads(read.columns.scope)) {
|
|
243
|
+
plan.skipped.push({ ref, why: 'its scope is none 0.14 writes', fault: 'tampered' });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (canonical(read.columns) !== canonical(columns) || read.columns.revoked) throw new Moved(`the binding ${vouched.id}'s row moved since the app vouched for it`);
|
|
247
|
+
plan.bindings.push({ ref, object: bindingObject({ ...read.columns, origin: vouched.origin }) });
|
|
248
|
+
}
|
|
249
|
+
return plan;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Each genesis as the change path's calls (`change_input`), an archived key's delete after its own, from the seal it leaves. */
|
|
253
|
+
export function genesisCalls(keys: ChangeKeys, genesis: readonly Genesis[], at: number): ChangeCall[] {
|
|
254
|
+
return genesis.flatMap(({ ref, object, old, deleted }) => {
|
|
255
|
+
const made = genesisCall(keys, ref, object, at, CUTOVER_ACTOR, old);
|
|
256
|
+
if (deleted !== true) return [made];
|
|
257
|
+
const row = { at, requestId: randomUUID(), object: objectKey(ref), actor: CUTOVER_ACTOR, payload: payloadOf(ref, { op: 'secret.delete' }), seal: null };
|
|
258
|
+
return [made, { ref, ...row, value: null, sealFrom: sealOf(keys.seals[0]!, ref, object), mac: entryMac(keys.mac, { tree: 'changes', row }) }];
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Whether a binding's stored scope is one: none, or JSON a credential's scope is. */
|
|
263
|
+
function scopeReads(stored: string | null): boolean {
|
|
264
|
+
if (stored === null) return true;
|
|
265
|
+
try {
|
|
266
|
+
return isCredentialScope(JSON.parse(stored));
|
|
267
|
+
} catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function baseSlug(slug: string): string {
|
|
273
|
+
const at = slug.indexOf(TOMBSTONE);
|
|
274
|
+
return at < 0 ? slug : slug.slice(0, at);
|
|
275
|
+
}
|