@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/cloudflare.ts
CHANGED
|
@@ -1,38 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The vault as a Worker, behind an entrypoint the app's `VAULT` service
|
|
3
3
|
* binding calls over RPC. It has no route and no HTTP surface. It decides
|
|
4
|
-
* in the database the app uses, through
|
|
5
|
-
*
|
|
4
|
+
* in the database the app uses, through two Hyperdrive bindings of its
|
|
5
|
+
* own, the vault's login and its uses' (`coffre_vault_hot`), and seals its
|
|
6
|
+
* log through the checkpointer's Durable Object:
|
|
6
7
|
*
|
|
7
|
-
* import { postgres, vault } from '@coffre/vault/cloudflare';
|
|
8
|
+
* import { checkpointer, postgres, vault } from '@coffre/vault/cloudflare';
|
|
8
9
|
*
|
|
9
10
|
* export default vault((env: Env) => ({
|
|
10
|
-
* database: postgres(env.
|
|
11
|
+
* database: postgres(env.VAULT_HYPERDRIVE),
|
|
12
|
+
* hotDatabase: postgres(env.HOT_HYPERDRIVE),
|
|
13
|
+
* checkpointer: env.CHECKPOINTER,
|
|
11
14
|
* kek: { id: env.VAULT_KEY_ID, key: env.VAULT_KEY }, // or awsKms({ keyArn, credentials }), with a signingKey
|
|
12
15
|
* owners: ['admin@acme.example'],
|
|
13
16
|
* }));
|
|
17
|
+
* export const CoffreCheckpointer = checkpointer();
|
|
14
18
|
*
|
|
15
19
|
* Any number of isolates run it side by side: every decision locks what it
|
|
16
20
|
* is about in the database, so they share one log and one bulk limit.
|
|
17
21
|
*/
|
|
18
|
-
import type {
|
|
22
|
+
import type { ChangeBindingsInput, CutoverInput,
|
|
19
23
|
AdmitInput,
|
|
20
24
|
RemoveInput,
|
|
25
|
+
RevokeCredentialInput,
|
|
26
|
+
IssueTokenInput,
|
|
21
27
|
ReferenceInput,
|
|
22
28
|
EndReferencesInput,
|
|
29
|
+
PrepareInput,
|
|
23
30
|
RewrapInput,
|
|
24
31
|
SetAccessInput,
|
|
25
32
|
SetSettingsInput,
|
|
33
|
+
SignInInput,
|
|
26
34
|
ChangePolicyInput,
|
|
27
35
|
UnwrapInput,
|
|
36
|
+
NamedUseInput,
|
|
37
|
+
UseManyInput,
|
|
38
|
+
UseInput,
|
|
28
39
|
Vault,
|
|
29
40
|
VerifyLogInput,
|
|
41
|
+
VouchInput,
|
|
30
42
|
WrapInput,
|
|
31
43
|
} from '@coffre/core/vault';
|
|
32
44
|
import { createDatabase } from '@coffre/db';
|
|
33
45
|
import { HyperdrivePool, type PostgresDatabase } from '@coffre/db/hyperdrive';
|
|
34
46
|
import { WorkerEntrypoint } from 'cloudflare:workers';
|
|
35
47
|
|
|
48
|
+
import type { Witness } from './checkpointer.ts';
|
|
49
|
+
import { checkpointerObject, durableHeldOutside, durableSealing, durableSealThrough, startCheckpointer, startProjector, type ObjectNamespace } from './checkpointer-workers.ts';
|
|
36
50
|
import { resolveVaultConfig, type VaultConfig } from './config.ts';
|
|
37
51
|
import { openVault, prepareVault, type PreparedVault } from './vault.ts';
|
|
38
52
|
|
|
@@ -40,8 +54,29 @@ export type { Vault, VaultConfig };
|
|
|
40
54
|
export { postgres, type PostgresDatabase } from '@coffre/db/hyperdrive';
|
|
41
55
|
export * from './index.ts';
|
|
42
56
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
57
|
+
/**
|
|
58
|
+
* What the vault Worker's `vault(env => …)` returns, for 0.15's log
|
|
59
|
+
* (docs/design/0.15-plan.md):
|
|
60
|
+
* - `hotDatabase`: the uses' own Hyperdrive config, whose login is
|
|
61
|
+
* `coffre_vault_hot`, and `uses`, how many uses an isolate has in flight
|
|
62
|
+
* at most (by default `connections`, the share of that config's pool an
|
|
63
|
+
* isolate may hold: T112 measures it), and `uses.batch`, the most keys
|
|
64
|
+
* a statement of a request's several makes uses of (100:
|
|
65
|
+
* `UseLimits.batch`);
|
|
66
|
+
* - `checkpointer`: the namespace of the checkpointer's Durable Object,
|
|
67
|
+
* `CoffreCheckpointer` (`checkpointer()`, below), bound beside the vault.
|
|
68
|
+
* A change answers once that object has sealed it (R51), and the
|
|
69
|
+
* verifier holds every check to the pair it remembers;
|
|
70
|
+
* - `witnesses` (docs/design/audit-tree.md, R40: a deployment opts in).
|
|
71
|
+
* A configuration without the first two is refused, at the first call.
|
|
72
|
+
*/
|
|
73
|
+
export type WorkersVaultConfig = VaultConfig & {
|
|
74
|
+
database: PostgresDatabase;
|
|
75
|
+
hotDatabase: PostgresDatabase;
|
|
76
|
+
checkpointer: ObjectNamespace;
|
|
77
|
+
witnesses?: readonly Witness[];
|
|
78
|
+
uses?: { inFlight?: number; connections?: number; coverMs?: number; names?: number; batch?: number };
|
|
79
|
+
};
|
|
45
80
|
|
|
46
81
|
/** Set when the Worker's module runs `vault(…)`, before any call comes in. */
|
|
47
82
|
let configure: ((env: never) => WorkersVaultConfig) | null = null;
|
|
@@ -51,7 +86,18 @@ let configure: ((env: never) => WorkersVaultConfig) | null = null;
|
|
|
51
86
|
* isolate keeps: the ready value itself, never the work of making it, which
|
|
52
87
|
* would belong to the call that started it (vault.ts, `PreparedVault`).
|
|
53
88
|
*/
|
|
54
|
-
const ready = new WeakMap<object,
|
|
89
|
+
const ready = new WeakMap<object, Ready>();
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* One `env`'s vault, ready: its databases, its prepared state, and whether
|
|
93
|
+
* a call of this isolate started the checkpointer, when a start last
|
|
94
|
+
* failed, and when a use last started one behind it: settled values, never
|
|
95
|
+
* the start under way.
|
|
96
|
+
*/
|
|
97
|
+
type Ready = { database: PostgresDatabase; hotDatabase: PostgresDatabase; checkpointer: ObjectNamespace; prepared: PreparedVault; started: boolean; failed: number; kicked: number };
|
|
98
|
+
|
|
99
|
+
/** How long after a start that failed, the checkpointer's object out of reach, the next call tries again. */
|
|
100
|
+
const START_AGAIN_MS = 10_000;
|
|
55
101
|
|
|
56
102
|
/**
|
|
57
103
|
* What the app's `VAULT` service binding calls. Each call gets a database of
|
|
@@ -60,7 +106,7 @@ const ready = new WeakMap<object, { database: PostgresDatabase; prepared: Prepar
|
|
|
60
106
|
* call is done.
|
|
61
107
|
*/
|
|
62
108
|
export class VaultEntrypoint extends WorkerEntrypoint implements Vault {
|
|
63
|
-
async #call<T>(run: (vault: Vault) => Promise<T>): Promise<T> {
|
|
109
|
+
async #call<T>(run: (vault: Required<Vault>) => Promise<T>): Promise<T> {
|
|
64
110
|
const { prepared, database } = await this.#ready();
|
|
65
111
|
const pool = new HyperdrivePool(database.hyperdrive.connectionString);
|
|
66
112
|
try {
|
|
@@ -70,15 +116,82 @@ export class VaultEntrypoint extends WorkerEntrypoint implements Vault {
|
|
|
70
116
|
}
|
|
71
117
|
}
|
|
72
118
|
|
|
73
|
-
|
|
119
|
+
/**
|
|
120
|
+
* 0.15's use: one connection of the hot path's config, for its one statement. Closed once the call is done, and by
|
|
121
|
+
* its deadline at the latest, so that a call answered `late` lets go of Hyperdrive by then, as its place in admission
|
|
122
|
+
* lapses soon after (use.ts, `HELD_PAST_DEADLINE_MS`; T170).
|
|
123
|
+
*/
|
|
124
|
+
async use(input: UseInput) {
|
|
125
|
+
const { prepared, hotDatabase } = this.#behind(await this.#prepared());
|
|
126
|
+
const pool = new HyperdrivePool(hotDatabase.hyperdrive.connectionString);
|
|
127
|
+
try {
|
|
128
|
+
const hot = createDatabase(pool);
|
|
129
|
+
return await openVault(hot, prepared, hot).use!(input);
|
|
130
|
+
} finally {
|
|
131
|
+
this.ctx.waitUntil(pool.end(closeBy(input)));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A request's several keys (T167): the same one connection, for its one statement, or two. */
|
|
136
|
+
async useMany(input: UseManyInput) {
|
|
137
|
+
const { prepared, hotDatabase } = this.#behind(await this.#prepared());
|
|
138
|
+
if (hotDatabase === undefined) throw new Error("0.15's uses need the hot path's Hyperdrive config: set hotDatabase in vault(…)");
|
|
139
|
+
const pool = new HyperdrivePool(hotDatabase.hyperdrive.connectionString);
|
|
140
|
+
try {
|
|
141
|
+
const hot = createDatabase(pool);
|
|
142
|
+
return await openVault(hot, prepared, hot).useMany!(input);
|
|
143
|
+
} finally {
|
|
144
|
+
this.ctx.waitUntil(pool.end(closeBy(input)));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 0.15's use as the request names it: the same one connection, for its read of ids on a miss and its one use. */
|
|
149
|
+
async useNamed(input: NamedUseInput) {
|
|
150
|
+
const { prepared, hotDatabase } = this.#behind(await this.#prepared());
|
|
151
|
+
const pool = new HyperdrivePool(hotDatabase.hyperdrive.connectionString);
|
|
152
|
+
try {
|
|
153
|
+
const hot = createDatabase(pool);
|
|
154
|
+
return await openVault(hot, prepared, hot).useNamed!(input);
|
|
155
|
+
} finally {
|
|
156
|
+
this.ctx.waitUntil(pool.end(closeBy(input)));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The vault ready, its checkpointer started from this call first, for at most 2 s (`start`). */
|
|
161
|
+
async #ready(): Promise<Ready> {
|
|
162
|
+
const entry = await this.#prepared();
|
|
163
|
+
if (!entry.started && Date.now() - entry.failed >= START_AGAIN_MS) await start(entry);
|
|
164
|
+
return entry;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A use waits on no start (T169, R58): it needs no checkpointer, the days of uses the migrations made and the object
|
|
169
|
+
* makes ahead of time aside. Its isolate's start goes on behind it, once each START_AGAIN_MS until one answers.
|
|
170
|
+
*/
|
|
171
|
+
#behind(entry: Ready): Ready {
|
|
172
|
+
if (!entry.started && Date.now() - entry.kicked >= START_AGAIN_MS) {
|
|
173
|
+
entry.kicked = Date.now();
|
|
174
|
+
this.ctx.waitUntil(start(entry));
|
|
175
|
+
}
|
|
176
|
+
return entry;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async #prepared(): Promise<Ready> {
|
|
74
180
|
const env = this.env as object;
|
|
75
181
|
let entry = ready.get(env);
|
|
76
182
|
if (entry === undefined) {
|
|
77
183
|
if (configure === null) throw new Error('the vault Worker must export default vault(…)');
|
|
78
184
|
const config = configure(env as never);
|
|
79
|
-
const
|
|
185
|
+
const { checkpointer: namespace, hotDatabase } = checked(config);
|
|
186
|
+
const prepared = await prepareVault(resolveVaultConfig(config), {
|
|
187
|
+
...(config.uses === undefined ? {} : { uses: config.uses }),
|
|
188
|
+
log: '0.15',
|
|
189
|
+
sealThrough: durableSealThrough(namespace),
|
|
190
|
+
heldOutside: durableHeldOutside(namespace),
|
|
191
|
+
callsEndWithRequest: true,
|
|
192
|
+
});
|
|
80
193
|
// Calls that raced here each made one; the first kept serves them all from now on.
|
|
81
|
-
entry = ready.get(env) ?? { database: config.database, prepared };
|
|
194
|
+
entry = ready.get(env) ?? { database: config.database, hotDatabase, checkpointer: namespace, prepared, started: false, failed: -Infinity, kicked: -Infinity };
|
|
82
195
|
ready.set(env, entry);
|
|
83
196
|
}
|
|
84
197
|
return entry;
|
|
@@ -98,9 +211,28 @@ export class VaultEntrypoint extends WorkerEntrypoint implements Vault {
|
|
|
98
211
|
async policy() { return this.#call((vault) => vault.policy()); }
|
|
99
212
|
async changePolicy(input: ChangePolicyInput) { return this.#call((vault) => vault.changePolicy(input)); }
|
|
100
213
|
async checkpoint() { return this.#call((vault) => vault.checkpoint()); }
|
|
101
|
-
|
|
214
|
+
/** What the vault says of itself; its uses over the hot path's own config (`use`), which `#call`'s vault has not. */
|
|
215
|
+
async about() {
|
|
216
|
+
const about = await this.#call((vault) => vault.about());
|
|
217
|
+
return about.log === '0.15' ? { ...about, uses: true as const, batchUses: true as const } : about;
|
|
218
|
+
}
|
|
102
219
|
async keyChecks() { return this.#call((vault) => vault.keyChecks()); }
|
|
103
220
|
async verifyLog(input: VerifyLogInput) { return this.#call((vault) => vault.verifyLog(input)); }
|
|
221
|
+
async prepare(input: PrepareInput) { return this.#call((vault) => vault.prepare(input)); }
|
|
222
|
+
async waitSealed(input: { number: string }) { return this.#call((vault) => vault.waitSealed(input)); }
|
|
223
|
+
async signIn(input: SignInInput) { return this.#call((vault) => vault.signIn(input)); }
|
|
224
|
+
async revokeCredential(input: RevokeCredentialInput) { return this.#call((vault) => vault.revokeCredential(input)); }
|
|
225
|
+
async issueToken(input: IssueTokenInput) { return this.#call((vault) => vault.issueToken(input)); }
|
|
226
|
+
async changeBindings(input: ChangeBindingsInput) { return this.#call((vault) => vault.changeBindings(input)); }
|
|
227
|
+
async cutover(input?: CutoverInput) { return this.#call((vault) => vault.cutover(input)); }
|
|
228
|
+
async verifyTrees() { return this.#call((vault) => vault.verifyTrees()); }
|
|
229
|
+
async verifyReads(input: { from: string }) { return this.#call((vault) => vault.verifyReads(input)); }
|
|
230
|
+
async vouch(input: VouchInput) { return this.#call((vault) => vault.vouch(input)); }
|
|
231
|
+
/** Whether the checkpointer seals (T169), asked of its object for at most 2 s; no database, and no start awaited. */
|
|
232
|
+
async sealing() {
|
|
233
|
+
const { checkpointer: namespace } = this.#behind(await this.#prepared());
|
|
234
|
+
return durableSealing(namespace)();
|
|
235
|
+
}
|
|
104
236
|
|
|
105
237
|
/** No HTTP surface: only the app's service binding reaches the vault. */
|
|
106
238
|
fetch() {
|
|
@@ -108,8 +240,70 @@ export class VaultEntrypoint extends WorkerEntrypoint implements Vault {
|
|
|
108
240
|
}
|
|
109
241
|
}
|
|
110
242
|
|
|
243
|
+
/** When a use's connection closes at the latest: its deadline, if it names one (`checkInput` refuses it otherwise). */
|
|
244
|
+
function closeBy(input: { deadline?: unknown } | undefined): number | undefined {
|
|
245
|
+
return typeof input?.deadline === 'number' && Number.isSafeInteger(input.deadline) ? input.deadline : undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* What 0.15's log needs, configured: a setting left out of `vault(…)` is refused here, by name. A Hyperdrive binding
|
|
250
|
+
* left out of wrangler.jsonc is refused before, by `postgres()`; a Durable Object's, here.
|
|
251
|
+
*/
|
|
252
|
+
function checked(config: WorkersVaultConfig): { hotDatabase: PostgresDatabase; checkpointer: ObjectNamespace } {
|
|
253
|
+
if (config.hotDatabase?.hyperdrive?.connectionString === undefined) {
|
|
254
|
+
throw new Error("the vault's uses run through a Hyperdrive config of their own: set hotDatabase in vault(…), postgres(env.HOT_HYPERDRIVE), its login coffre_vault_hot");
|
|
255
|
+
}
|
|
256
|
+
if (typeof config.checkpointer?.idFromName !== 'function') {
|
|
257
|
+
throw new Error("the vault answers a change once its checkpointer has sealed it: set checkpointer in vault(…), env.CHECKPOINTER, and bind the CoffreCheckpointer Durable Object beside the vault");
|
|
258
|
+
}
|
|
259
|
+
return { hotDatabase: config.hotDatabase, checkpointer: config.checkpointer };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The checkpointer's object started, from a call of this isolate, before it
|
|
264
|
+
* goes on: its alarm set, and it open, so that no call meets a log with no
|
|
265
|
+
* checkpoint yet. Started again is harmless: until one start has answered,
|
|
266
|
+
* each call makes its own and waits for it, never for another call's,
|
|
267
|
+
* which a call may not await on Workers (AGENTS.md).
|
|
268
|
+
* An object that refuses to open (a log it cannot take: docs/restore.md) is
|
|
269
|
+
* started all the same, its alarm set, and says why in its alarms; one out
|
|
270
|
+
* of reach, or that does not answer within 2 s (T169), is tried again by a
|
|
271
|
+
* call 10 s later. Neither stops this call: a use needs no checkpointer (and
|
|
272
|
+
* waits on no start: `#behind`), and a change fails on its seal.
|
|
273
|
+
*/
|
|
274
|
+
async function start(entry: Ready): Promise<void> {
|
|
275
|
+
// The reads' projector beside it (T166), an object of its own, which opens nothing to start: one that fails to is
|
|
276
|
+
// started by the next isolate's first call, and the audit pages say how far behind their reads are meanwhile.
|
|
277
|
+
const projecting = startProjector(entry.checkpointer).catch((error: Error) => console.warn(`coffre vault: the reads' projector did not start: ${error.message}`));
|
|
278
|
+
try {
|
|
279
|
+
await startCheckpointer(entry.checkpointer);
|
|
280
|
+
entry.started = true;
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if ((error as { status?: number }).status === 503) entry.started = true;
|
|
283
|
+
else entry.failed = Date.now();
|
|
284
|
+
console.warn(`coffre vault: ${(error as Error).message}`);
|
|
285
|
+
}
|
|
286
|
+
await projecting;
|
|
287
|
+
}
|
|
288
|
+
|
|
111
289
|
/** The vault Worker's default export, `VaultEntrypoint`, configured from its `env`. */
|
|
112
290
|
export function vault<Env>(configure_: (env: Env) => WorkersVaultConfig): typeof VaultEntrypoint {
|
|
113
291
|
configure = configure_ as (env: never) => WorkersVaultConfig;
|
|
114
292
|
return VaultEntrypoint;
|
|
115
293
|
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 0.15's checkpointer, the Durable Object class a deployment binds beside
|
|
297
|
+
* the vault, configured by the same `vault(env => …)`: its Hyperdrive, its
|
|
298
|
+
* signing key, its witnesses. `export const CoffreCheckpointer = checkpointer()`.
|
|
299
|
+
*/
|
|
300
|
+
export function checkpointer() {
|
|
301
|
+
return checkpointerObject((env: object) => {
|
|
302
|
+
if (configure === null) throw new Error('the vault Worker must export default vault(…)');
|
|
303
|
+
const config = configure(env as never);
|
|
304
|
+
return { hyperdrive: config.database.hyperdrive, seed: resolveVaultConfig(config).signingKeys[0]!, witnesses: config.witnesses };
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export { checkpointerAlarms, checkpointerStatus, durableHeldOutside, durableSealing, durableSealThrough, startCheckpointer, startProjector, type ObjectNamespace } from './checkpointer-workers.ts';
|
|
309
|
+
export { durableWitness, r2Archive, witnessObject as witness, type WitnessSettings } from './witness-workers.ts';
|
package/src/config.ts
CHANGED
|
@@ -11,6 +11,14 @@ export type BulkLimit = { count: number; windowMs: number };
|
|
|
11
11
|
*/
|
|
12
12
|
export const DEFAULT_BULK_LIMIT: BulkLimit = { count: 1000, windowMs: 15 * 60_000 };
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The longest bulk limit window: a day. The releases a window counts are
|
|
16
|
+
* kept a day and an hour (migration 0021's `prune_vault_releases`, and on
|
|
17
|
+
* SQLite `RELEASES_KEPT_MS`), so every window up to it, and one widened
|
|
18
|
+
* since, counts exactly.
|
|
19
|
+
*/
|
|
20
|
+
export const MAX_BULK_WINDOW_MINUTES = 24 * 60;
|
|
21
|
+
|
|
14
22
|
/** The vault as it runs: keys decoded, emails checked. */
|
|
15
23
|
export type ResolvedVaultConfig = {
|
|
16
24
|
keks: KekRegistry;
|
|
@@ -61,7 +69,10 @@ export type VaultConfig = {
|
|
|
61
69
|
* service holds the KEK, as AWS KMS does: the vault never sees that key.
|
|
62
70
|
*/
|
|
63
71
|
signingKey?: string;
|
|
64
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. The
|
|
74
|
+
* window is a day at most: the releases it counts are kept a day and an hour (`prune_vault_releases`).
|
|
75
|
+
*/
|
|
65
76
|
bulkLimit?: { count: number; windowMinutes: number };
|
|
66
77
|
/**
|
|
67
78
|
* @deprecated Gone in 0.10, and refused: each key says who sees it now. Mark
|
|
@@ -176,8 +187,9 @@ function signingKeys(given: string | undefined, keks: { provider: KekProvider; s
|
|
|
176
187
|
|
|
177
188
|
|
|
178
189
|
function checkBulkLimit({ count, windowMinutes }: { count: number; windowMinutes: number }): BulkLimit {
|
|
179
|
-
|
|
180
|
-
|
|
190
|
+
// A whole number of milliseconds: what the 0.15 log's policy holds (`bulk.limit` in inputs.ts).
|
|
191
|
+
if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0) || windowMinutes > MAX_BULK_WINDOW_MINUTES || !Number.isInteger(windowMinutes * 60_000)) {
|
|
192
|
+
throw new Error(`bulkLimit needs a whole count of at least 1 and a window above 0 minutes, a day (${MAX_BULK_WINDOW_MINUTES}) at most, a whole number of milliseconds`);
|
|
181
193
|
}
|
|
182
194
|
return { count, windowMs: windowMinutes * 60_000 };
|
|
183
195
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { MAX_BINDINGS } from '@coffre/core/identity';
|
|
2
|
+
import type { Queryable } from '@coffre/db';
|
|
3
|
+
import { rowsOf } from '@coffre/db/dialect';
|
|
4
|
+
import { sql } from 'drizzle-orm';
|
|
5
|
+
|
|
6
|
+
import type { Answer, BindingsCall, IssueCall, RevokeCall, SignInCall } from './credentials.ts';
|
|
7
|
+
|
|
8
|
+
/** `sign_in` (migration 0037): one statement, its own transaction, its answer one JSON value. */
|
|
9
|
+
export async function signInOnPostgres(db: Queryable, c: SignInCall): Promise<Answer> {
|
|
10
|
+
const k = c.credential;
|
|
11
|
+
const [row] = await rowsOf<{ answer: string }>(db, sql`/* writes */ SELECT sign_in(
|
|
12
|
+
${c.requestId}::text, ${String(c.at)}::bigint, ${c.actor}::text, ${c.payload}::text, ${c.mac}::bytea, ${c.seal}::bytea,
|
|
13
|
+
${c.binding.id}::uuid, ${c.principal}::text, ${c.binding.generation}::integer, ${c.binding.profile}::text, ${c.binding.scope}::text, ${c.binding.policy}::text,
|
|
14
|
+
${c.binding.issuer}::text, ${c.binding.jwksUri}::text, ${c.binding.claims}::text, ${c.bindingSeal}::bytea,
|
|
15
|
+
${c.spent}::bytea, ${c.validUntil === null ? null : String(c.validUntil)}::bigint, ${c.frozen}::boolean, ${c.rate}::integer, ${String(c.window)}::bigint, ${String(c.longest)}::bigint,
|
|
16
|
+
${k.id}::uuid, ${k.kind}::text, ${k.authMacBytes}::bytea, ${k.tokenHashBytes}::bytea, ${k.tokenHint}::text, ${k.label}::text, ${k.createdBy}::text,
|
|
17
|
+
${k.scope}::text, ${k.places}::text, ${String(k.expiresAt)}::bigint, ${k.notAfter === null ? null : String(k.notAfter)}::bigint, ${k.jkt}::text, ${c.appKey}::text
|
|
18
|
+
)::text AS answer`);
|
|
19
|
+
return JSON.parse(row!.answer) as Answer;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `revoke_credential` (migration 0034): one statement, its own transaction, its answer one JSON value. */
|
|
23
|
+
export async function revokeOnPostgres(db: Queryable, c: RevokeCall): Promise<Answer> {
|
|
24
|
+
const { change, policy } = c;
|
|
25
|
+
const guarded = policy?.protected ?? [];
|
|
26
|
+
const [row] = await rowsOf<{ answer: string }>(db, sql`/* writes */ SELECT revoke_credential(
|
|
27
|
+
${change.requestId}::text, ${String(change.at)}::bigint, ${change.actor}::text, ${change.payload}::text, ${change.mac}::bytea,
|
|
28
|
+
${change.sealFrom}::bytea, ${change.seal}::bytea,
|
|
29
|
+
${c.ref.id}::uuid, ${c.macFrom}::bytea, ${c.mac}::bytea, ${String(c.revokedAt)}::bigint, ${c.revokedBy}::text,
|
|
30
|
+
${policy !== null}::boolean, ${policy?.seq ?? null}::bigint,
|
|
31
|
+
${sql.param(guarded.map(({ environmentId }) => environmentId))}::uuid[], ${sql.param(guarded.map(({ slug }) => slug))}::text[]
|
|
32
|
+
)::text AS answer`);
|
|
33
|
+
return JSON.parse(row!.answer) as Answer;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `issue_token` (migration 0036): one statement, its own transaction, its answer one JSON value. */
|
|
37
|
+
export async function issueOnPostgres(db: Queryable, c: IssueCall): Promise<Answer> {
|
|
38
|
+
const { change, input, read } = c;
|
|
39
|
+
const k = input.credential;
|
|
40
|
+
const guarded = read.slugs;
|
|
41
|
+
const [row] = await rowsOf<{ answer: string }>(db, sql`/* writes */ SELECT issue_token(
|
|
42
|
+
${change.requestId}::text, ${String(change.at)}::bigint, ${change.actor}::text, ${change.payload}::text, ${change.mac}::bytea, ${change.seal}::bytea,
|
|
43
|
+
${input.principal}::text, ${input.generation}::integer, ${read.principal.accessSeq.toString()}::bigint, ${read.actor.generation}::integer, ${read.actor.accessSeq.toString()}::bigint,
|
|
44
|
+
${input.via}::uuid, ${read.via?.seal ?? null}::bytea, ${read.via?.columns.expiresAt == null ? null : String(read.via.columns.expiresAt)}::bigint,
|
|
45
|
+
${input.policy.seq}::bigint, ${sql.param(guarded.map(({ environmentId }) => environmentId))}::uuid[], ${sql.param(guarded.map(({ slug }) => slug))}::text[], ${input.appKey}::text,
|
|
46
|
+
${k.id}::uuid, ${k.kind}::text, ${c.authMac}::bytea, ${c.tokenHash}::bytea, ${k.tokenHint}::text, ${k.label}::text, ${k.createdBy}::text,
|
|
47
|
+
${k.scope}::text, ${k.places}::text, ${String(k.expiresAt)}::bigint
|
|
48
|
+
)::text AS answer`);
|
|
49
|
+
return JSON.parse(row!.answer) as Answer;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `change_bindings` (migrations 0037, 0040): one statement, its own transaction, its answer one JSON value. */
|
|
53
|
+
export async function bindingsOnPostgres(db: Queryable, c: BindingsCall): Promise<Answer> {
|
|
54
|
+
const m = c.make;
|
|
55
|
+
const r = m?.read ?? null;
|
|
56
|
+
const b = m?.row ?? null;
|
|
57
|
+
const array = <T>(values: T[]) => sql.param(values);
|
|
58
|
+
const [row] = await rowsOf<{ answer: string }>(db, sql`/* writes */ SELECT change_bindings(
|
|
59
|
+
${String(c.at)}::bigint, ${c.actor}::text,
|
|
60
|
+
${m !== null}::boolean, ${c.principal}::text, ${b?.generation ?? null}::integer, ${r?.principal.accessSeq.toString() ?? null}::bigint,
|
|
61
|
+
${r?.actor.generation ?? null}::integer, ${r?.actor.accessSeq.toString() ?? null}::bigint,
|
|
62
|
+
${m?.via ?? null}::uuid, ${r?.via?.seal ?? null}::bytea, ${r?.via?.columns.expiresAt == null ? null : String(r.via.columns.expiresAt)}::bigint,
|
|
63
|
+
${m?.connection?.id ?? null}::uuid, ${m?.connection?.generation ?? null}::integer,
|
|
64
|
+
${c.policy.seq}::bigint, ${array(c.policy.slugs.map(({ environmentId }) => environmentId))}::uuid[], ${array(c.policy.slugs.map(({ slug }) => slug))}::text[], ${c.appKey}::text,
|
|
65
|
+
${MAX_BINDINGS}::integer, ${c.live === null ? null : array(c.live)}::uuid[], ${array(c.reads.map((read) => read.id))}::uuid[], ${array(c.reads.map((read) => read.key))}::text[],
|
|
66
|
+
${b?.id ?? null}::uuid, ${m?.authMac ?? null}::bytea, ${b?.profile ?? null}::text, ${b?.issuer ?? null}::text, ${b?.jwksUri ?? null}::text, ${b?.jwks ?? null}::text,
|
|
67
|
+
${b?.claims ?? null}::text, ${b?.scope ?? null}::text, ${b?.policy ?? null}::text, ${b?.origin ?? null}::text, ${b?.label ?? null}::text, ${b?.createdBy ?? null}::text,
|
|
68
|
+
${b === null ? null : String(b.createdAt)}::bigint, ${m?.seal ?? null}::bytea,
|
|
69
|
+
${array(c.ends.map((e) => e.id))}::uuid[], ${array(c.ends.map((e) => e.macFrom))}::bytea[], ${array(c.ends.map((e) => e.sealFrom))}::bytea[],
|
|
70
|
+
${array(c.ends.map((e) => e.authMac))}::bytea[], ${array(c.ends.map((e) => e.seal))}::bytea[],
|
|
71
|
+
${array(c.credentials.map((x) => x.id))}::uuid[], ${array(c.credentials.map((x) => x.binding))}::uuid[], ${array(c.credentials.map((x) => x.macFrom))}::bytea[],
|
|
72
|
+
${array(c.credentials.map((x) => x.sealFrom))}::bytea[], ${array(c.credentials.map((x) => x.authMac))}::bytea[], ${array(c.credentials.map((x) => x.seal))}::bytea[],
|
|
73
|
+
${String(c.revokedAt)}::bigint, ${c.revokedBy}::text,
|
|
74
|
+
${array(c.changes.map((x) => x.requestId))}::text[], ${array(c.changes.map((x) => x.object))}::uuid[], ${array(c.changes.map((x) => x.payload))}::text[],
|
|
75
|
+
${array(c.changes.map((x) => x.seal))}::bytea[], ${array(c.changes.map((x) => x.mac))}::bytea[]
|
|
76
|
+
)::text AS answer`);
|
|
77
|
+
return JSON.parse(row!.answer) as Answer;
|
|
78
|
+
}
|