@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.
Files changed (43) hide show
  1. package/README.md +6 -4
  2. package/dist/cloudflare.d.ts +341 -4
  3. package/dist/cloudflare.js +802 -6
  4. package/dist/index.js +1 -1
  5. package/dist/log-entry-BYWqj3O5.d.ts +364 -0
  6. package/dist/node.d.ts +181 -6
  7. package/dist/node.js +358 -16
  8. package/dist/{src-Bg0ZeMJW.js → src-DheG92EM.js} +1 -1
  9. package/dist/vault-kmtojYnW.js +14623 -0
  10. package/package.json +4 -5
  11. package/src/bulk.ts +89 -0
  12. package/src/changes.ts +478 -0
  13. package/src/checkpointer-node.ts +342 -0
  14. package/src/checkpointer-workers.ts +514 -0
  15. package/src/checkpointer.ts +1165 -0
  16. package/src/cloudflare.ts +207 -13
  17. package/src/config.ts +3 -2
  18. package/src/credentials.postgres.ts +78 -0
  19. package/src/credentials.sqlite.ts +240 -0
  20. package/src/credentials.ts +630 -0
  21. package/src/cutover.ts +275 -0
  22. package/src/decide.ts +437 -2
  23. package/src/inputs.ts +499 -0
  24. package/src/local.ts +20 -4
  25. package/src/log-checkpointer.ts +116 -0
  26. package/src/log-entry.ts +113 -0
  27. package/src/log-replay.ts +188 -0
  28. package/src/log-store.ts +695 -0
  29. package/src/node.ts +73 -9
  30. package/src/objects.ts +141 -0
  31. package/src/reads.ts +502 -0
  32. package/src/retention.ts +229 -0
  33. package/src/snapshot.postgres.ts +39 -9
  34. package/src/snapshot.sqlite.ts +28 -3
  35. package/src/snapshot.ts +64 -10
  36. package/src/store.ts +832 -36
  37. package/src/use.postgres.ts +111 -0
  38. package/src/use.sqlite.ts +542 -0
  39. package/src/use.ts +1060 -0
  40. package/src/vault.ts +1366 -220
  41. package/src/verifier.ts +136 -0
  42. package/src/witness-workers.ts +163 -0
  43. package/dist/vault-Cbi45kLB.js +0 -5197
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 a Hyperdrive binding of its own,
5
- * whose login is the vault's:
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.HYPERDRIVE),
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
- /** What the vault Worker's `vault(env => …)` returns. */
44
- export type WorkersVaultConfig = VaultConfig & { database: PostgresDatabase };
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, { database: PostgresDatabase; prepared: PreparedVault }>();
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
- async #ready(): Promise<{ database: PostgresDatabase; prepared: PreparedVault }> {
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 prepared = await prepareVault(resolveVaultConfig(config));
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
- async about() { return this.#call((vault) => vault.about()); }
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
@@ -187,8 +187,9 @@ function signingKeys(given: string | undefined, keks: { provider: KekProvider; s
187
187
 
188
188
 
189
189
  function checkBulkLimit({ count, windowMinutes }: { count: number; windowMinutes: number }): BulkLimit {
190
- if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0) || windowMinutes > MAX_BULK_WINDOW_MINUTES) {
191
- 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`);
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`);
192
193
  }
193
194
  return { count, windowMs: windowMinutes * 60_000 };
194
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
+ }
@@ -0,0 +1,240 @@
1
+ import { MAX_BINDINGS } from '@coffre/core/identity';
2
+ import { tablesOf, type Queryable } from '@coffre/db';
3
+ import { and, desc, eq, gt, inArray, isNull, sql } from 'drizzle-orm';
4
+
5
+ import type { Answer, BindingsCall, IssueCall, RevokeCall, SignInCall } from './credentials.ts';
6
+ import { objectKey } from './inputs.ts';
7
+
8
+ /**
9
+ * `sign_in` on SQLite, step for step, in one transaction: SQLite runs one
10
+ * writer at a time, so its locks are implicit, and an entry's number is
11
+ * one past the log's highest.
12
+ */
13
+ export async function signInOnSqlite(tx: Queryable, c: SignInCall): Promise<Answer> {
14
+ const { auditLog, consumedTokens, credentials, logChanges, serviceBindings, vaultMembers } = tablesOf(tx);
15
+ const target = objectKey({ kind: 'credential', id: c.credential.id });
16
+ const k = c.credential;
17
+ const [member] = await tx.select({ status: vaultMembers.status, generation: vaultMembers.generation, seal: vaultMembers.seal }).from(vaultMembers).where(eq(vaultMembers.principal, c.principal));
18
+ if (member?.status !== 'active' || member.generation !== c.binding.generation) return { state: 'unbound' };
19
+ // Its row as its newest change sealed it, or 0.14's with neither: a row put back is none.
20
+ const [sealedAs] = await tx.select({ seal: logChanges.seal }).from(logChanges).where(eq(logChanges.object, objectKey({ kind: 'principal', id: c.principal }))).orderBy(desc(logChanges.number)).limit(1);
21
+ if (!same(member.seal ?? null, sealedAs?.seal ?? null)) return { state: 'unbound' };
22
+ // The binding as the vault checked its seal, and that seal its object's newest change's (BindingSealed).
23
+ const [binding] = await tx.select().from(serviceBindings).where(eq(serviceBindings.id, c.binding.id));
24
+ const [bindingSealedAs] = await tx.select({ seal: logChanges.seal }).from(logChanges).where(eq(logChanges.object, objectKey({ kind: 'binding', id: c.binding.id }))).orderBy(desc(logChanges.number)).limit(1);
25
+ if (binding === undefined || binding.principal !== c.principal || binding.generation !== c.binding.generation || binding.revokedAt !== null || binding.profile !== c.binding.profile
26
+ || binding.scope !== c.binding.scope || binding.policy !== c.binding.policy || binding.issuer !== c.binding.issuer || binding.jwksUri !== c.binding.jwksUri
27
+ || binding.claims !== c.binding.claims || !same(binding.seal ?? null, c.bindingSeal) || !same(bindingSealedAs?.seal ?? null, c.bindingSeal)) {
28
+ return { state: 'unbound' };
29
+ }
30
+ const [moved] = await tx.select({ seq: auditLog.seq }).from(auditLog)
31
+ .where(and(eq(auditLog.author, 'app'), eq(auditLog.subjectPrincipal, c.appKey), inArray(auditLog.action, ['key.reseal', 'key.rotate']))).limit(1);
32
+ if (moved !== undefined) return { state: 'retired' };
33
+ const named = (key: string) => sql`json_extract(${auditLog.metadata}, ${`$.${key}`})`;
34
+ if (c.frozen) {
35
+ for (const target of ['instance', c.principal, `binding:${c.binding.id}`]) {
36
+ const [newest] = await tx.select({ action: auditLog.action }).from(auditLog)
37
+ .where(and(eq(auditLog.author, 'app'), inArray(auditLog.action, ['workload.freeze', 'workload.unfreeze']), eq(auditLog.decision, 'allow'), eq(named('target'), target)))
38
+ .orderBy(desc(auditLog.seq)).limit(1);
39
+ if (newest?.action === 'workload.freeze') return { state: 'frozen' };
40
+ }
41
+ }
42
+ const now = Date.now();
43
+ const recent = await tx.select({ id: credentials.id }).from(credentials)
44
+ .where(and(eq(credentials.principal, c.principal), eq(credentials.createdBy, k.createdBy), gt(credentials.createdAt, new Date(now - c.window))))
45
+ .limit(c.rate);
46
+ if (recent.length >= c.rate) return { state: 'busy' };
47
+ if ((c.validUntil !== null && c.validUntil <= now) || k.expiresAt <= now || k.expiresAt > now + c.longest + 60_000 || (k.notAfter !== null && (k.notAfter <= now || k.expiresAt > k.notAfter))) return { state: 'expired' };
48
+ if (c.spent !== null) {
49
+ const spent = await tx.insert(consumedTokens).values({ hash: c.spent }).onConflictDoNothing().returning({ hash: consumedTokens.hash });
50
+ if (spent.length === 0) return { state: 'replayed' };
51
+ }
52
+ await tx.insert(credentials).values({
53
+ id: k.id, kind: k.kind, authMac: k.authMacBytes, tokenHash: k.tokenHashBytes, tokenHint: k.tokenHint, generation: c.binding.generation,
54
+ principal: c.principal, identityId: null, scope: k.scope, places: k.places, label: k.label, createdBy: k.createdBy,
55
+ expiresAt: new Date(k.expiresAt), revokedAt: null, jkt: k.jkt, notAfter: k.notAfter === null ? null : new Date(k.notAfter), seal: c.seal,
56
+ });
57
+ await tx.update(serviceBindings).set({ lastUsedAt: new Date(now) }).where(eq(serviceBindings.id, c.binding.id));
58
+ const number = await nextNumber(tx);
59
+ await tx.insert(logChanges).values({ number, at: c.at, requestId: c.requestId, object: target, actor: c.actor, payload: c.payload, seal: c.seal, mac: c.mac });
60
+ return { number: number.toString() };
61
+ }
62
+
63
+ /** `revoke_credential` on SQLite, step for step, in one transaction. */
64
+ export async function revokeOnSqlite(tx: Queryable, c: RevokeCall): Promise<Answer> {
65
+ const { auditLog, credentials, environments, logChanges, projects } = tablesOf(tx);
66
+ const { change, policy } = c;
67
+ const [row] = await tx.select({ seal: credentials.seal, authMac: credentials.authMac, revokedAt: credentials.revokedAt }).from(credentials).where(eq(credentials.id, c.ref.id));
68
+ if (row === undefined || row.revokedAt !== null || !same(row.authMac, c.macFrom) || !same(row.seal ?? null, change.sealFrom)) return { state: 'moved' };
69
+ const [newest] = await tx.select({ seal: logChanges.seal }).from(logChanges).where(eq(logChanges.object, change.object)).orderBy(desc(logChanges.number)).limit(1);
70
+ if (!same(newest?.seal ?? null, change.sealFrom)) return { state: 'stale' };
71
+ if (policy !== null) {
72
+ const [changed] = await tx.select({ seq: auditLog.seq }).from(auditLog)
73
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'policy.change'), eq(auditLog.decision, 'allow'))).orderBy(desc(auditLog.seq)).limit(1);
74
+ if ((changed === undefined ? null : String(changed.seq)) !== policy.seq) return { state: 'policy' };
75
+ for (const { environmentId, slug } of policy.protected) {
76
+ const [place] = await tx.select({ slug: environments.slug, project: projects.slug }).from(environments)
77
+ .innerJoin(projects, eq(projects.id, environments.projectId)).where(eq(environments.id, environmentId));
78
+ const standing = place === undefined || place.slug.includes('~') || place.project.includes('~') ? null : place.slug;
79
+ if (standing !== slug) return { state: 'policy' };
80
+ }
81
+ }
82
+ await tx.update(credentials).set({ revokedAt: new Date(c.revokedAt), revokedBy: c.revokedBy, authMac: c.mac, seal: change.seal })
83
+ .where(and(eq(credentials.id, c.ref.id), isNull(credentials.revokedAt)));
84
+ const number = await nextNumber(tx);
85
+ await tx.insert(logChanges).values({ number, at: change.at, requestId: change.requestId, object: change.object, actor: change.actor, payload: change.payload, seal: change.seal, mac: change.mac });
86
+ return { number: number.toString() };
87
+ }
88
+
89
+ /** `issue_token` on SQLite, step for step, in one transaction: its one writer holds every row as read. */
90
+ export async function issueOnSqlite(tx: Queryable, c: IssueCall): Promise<Answer> {
91
+ const { auditLog, credentials, environments, logChanges, projects, vaultMembers } = tablesOf(tx);
92
+ const { change, input, read } = c;
93
+ const k = input.credential;
94
+ const standing = async (principal: string, generation: number, accessSeq: bigint) => {
95
+ const [row] = await tx.select({ status: vaultMembers.status, generation: vaultMembers.generation, accessSeq: vaultMembers.accessSeq }).from(vaultMembers).where(eq(vaultMembers.principal, principal));
96
+ return row?.status === 'active' && row.generation === generation && BigInt(row.accessSeq) === accessSeq;
97
+ };
98
+ if (!(await standing(input.actor, read.actor.generation, read.actor.accessSeq)) || !(await standing(input.principal, input.generation, read.principal.accessSeq))) return { state: 'moved' };
99
+ const now = Date.now();
100
+ if (input.via !== null) {
101
+ const [via] = await tx.select({ principal: credentials.principal, revokedAt: credentials.revokedAt, seal: credentials.seal, expiresAt: credentials.expiresAt, notAfter: credentials.notAfter })
102
+ .from(credentials).where(eq(credentials.id, input.via));
103
+ const [newest] = await tx.select({ seal: logChanges.seal }).from(logChanges).where(eq(logChanges.object, objectKey({ kind: 'credential', id: input.via }))).orderBy(desc(logChanges.number)).limit(1);
104
+ // Its end as the vault authenticated it, still the columns' and still ahead (as `issue_token`).
105
+ const sealedAs = read.via?.seal ?? null;
106
+ const end = read.via?.columns.expiresAt ?? null;
107
+ const ends = via === undefined ? null : Math.min(via.expiresAt.getTime(), via.notAfter?.getTime() ?? Infinity);
108
+ if (via === undefined || via.principal !== input.actor || via.revokedAt !== null || sealedAs === null || end === null || ends !== end || end <= now
109
+ || !same(via.seal ?? null, sealedAs) || !same(newest?.seal ?? null, sealedAs)) {
110
+ return { state: 'ended' };
111
+ }
112
+ }
113
+ const [changed] = await tx.select({ seq: auditLog.seq }).from(auditLog)
114
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'policy.change'), eq(auditLog.decision, 'allow'))).orderBy(desc(auditLog.seq)).limit(1);
115
+ if ((changed === undefined ? null : String(changed.seq)) !== input.policy.seq) return { state: 'policy' };
116
+ for (const { environmentId, slug } of read.slugs) {
117
+ const [place] = await tx.select({ slug: environments.slug, project: projects.slug }).from(environments).innerJoin(projects, eq(projects.id, environments.projectId)).where(eq(environments.id, environmentId));
118
+ if ((place === undefined || place.slug.includes('~') || place.project.includes('~') ? null : place.slug) !== slug) return { state: 'policy' };
119
+ }
120
+ const [moved] = await tx.select({ seq: auditLog.seq }).from(auditLog)
121
+ .where(and(eq(auditLog.author, 'app'), eq(auditLog.subjectPrincipal, input.appKey), inArray(auditLog.action, ['key.reseal', 'key.rotate']))).limit(1);
122
+ if (moved !== undefined) return { state: 'retired' };
123
+ if (k.expiresAt <= now) return { state: 'expired' };
124
+ await tx.insert(credentials).values({
125
+ id: k.id, kind: k.kind, authMac: c.authMac, tokenHash: c.tokenHash, tokenHint: k.tokenHint, generation: input.generation, principal: input.principal,
126
+ identityId: null, scope: k.scope, places: k.places, label: k.label, createdBy: k.createdBy, expiresAt: new Date(k.expiresAt), revokedAt: null, seal: change.seal,
127
+ });
128
+ const number = await nextNumber(tx);
129
+ await tx.insert(logChanges).values({ number, at: change.at, requestId: change.requestId, object: change.object, actor: change.actor, payload: change.payload, seal: change.seal, mac: change.mac });
130
+ return { number: number.toString() };
131
+ }
132
+
133
+ /** `change_bindings` on SQLite, step for step, in one transaction: SQLite runs one writer at a time. */
134
+ export async function bindingsOnSqlite(tx: Queryable, c: BindingsCall): Promise<Answer> {
135
+ const { auditLog, credentials, environments, logChanges, mcpConnections, projects, secrets, serviceBindings, vaultMembers } = tablesOf(tx);
136
+ const now = Date.now();
137
+ const newest = async (ref: Parameters<typeof objectKey>[0]) =>
138
+ (await tx.select({ seal: logChanges.seal }).from(logChanges).where(eq(logChanges.object, objectKey(ref))).orderBy(desc(logChanges.number)).limit(1))[0]?.seal ?? null;
139
+ const m = c.make;
140
+ if (m !== null) {
141
+ const standing = async (principal: string, generation: number, accessSeq: bigint) => {
142
+ const [row] = await tx.select({ status: vaultMembers.status, generation: vaultMembers.generation, accessSeq: vaultMembers.accessSeq }).from(vaultMembers).where(eq(vaultMembers.principal, principal));
143
+ return row?.status === 'active' && row.generation === generation && BigInt(row.accessSeq) === accessSeq;
144
+ };
145
+ if (!(await standing(c.actor, m.read.actor.generation, m.read.actor.accessSeq)) || !(await standing(c.principal, m.row.generation, m.read.principal.accessSeq))) return { state: 'moved' };
146
+ if (m.via !== null) {
147
+ const [via] = await tx.select({ principal: credentials.principal, revokedAt: credentials.revokedAt, seal: credentials.seal, expiresAt: credentials.expiresAt, notAfter: credentials.notAfter })
148
+ .from(credentials).where(eq(credentials.id, m.via));
149
+ const sealedAs = m.read.via?.seal ?? null;
150
+ const end = m.read.via?.columns.expiresAt ?? null;
151
+ const ends = via === undefined ? null : Math.min(via.expiresAt.getTime(), via.notAfter?.getTime() ?? Infinity);
152
+ if (via === undefined || via.principal !== c.actor || via.revokedAt !== null || sealedAs === null || end === null || ends !== end || end <= now
153
+ || !same(via.seal ?? null, sealedAs) || !same(await newest({ kind: 'credential', id: m.via }), sealedAs)) {
154
+ return { state: 'ended' };
155
+ }
156
+ }
157
+ // The MCP connection it came through, still connected; an id the log holds already, no binding to make (review r3).
158
+ if (m.connection !== null) {
159
+ const [connection] = await tx.select({ revokedAt: mcpConnections.revokedAt, generation: mcpConnections.generation }).from(mcpConnections).where(eq(mcpConnections.id, m.connection.id));
160
+ if (connection === undefined || connection.revokedAt !== null || connection.generation !== m.connection.generation) return { state: 'ended' };
161
+ }
162
+ const [made] = await tx.select({ number: logChanges.number }).from(logChanges).where(eq(logChanges.object, objectKey({ kind: 'binding', id: m.row.id }))).limit(1);
163
+ if (made !== undefined) return { state: 'stale' };
164
+ }
165
+ // The account's live bindings as the app read them, at most `MAX_BINDINGS` besides those it ends; the keys it reads by their names.
166
+ if (m !== null) {
167
+ const live = await tx.select({ id: serviceBindings.id }).from(serviceBindings)
168
+ .where(and(eq(serviceBindings.principal, c.principal), eq(serviceBindings.generation, m.row.generation), isNull(serviceBindings.revokedAt)));
169
+ if (live.map(({ id }) => id).sort().join() !== [...(c.live ?? [])].sort().join()) return { state: 'moved' };
170
+ if (live.filter(({ id }) => !c.ends.some((end) => end.id === id)).length >= MAX_BINDINGS) return { state: 'full' };
171
+ for (const read of c.reads) {
172
+ const [row] = await tx.select({ key: secrets.key }).from(secrets).where(eq(secrets.id, read.id));
173
+ if (row?.key !== read.key) return { state: 'moved' };
174
+ }
175
+ }
176
+ // The bindings it ends, and every live credential they issued, as read.
177
+ for (const end of c.ends) {
178
+ const [row] = await tx.select({ principal: serviceBindings.principal, revokedAt: serviceBindings.revokedAt, authMac: serviceBindings.authMac, seal: serviceBindings.seal })
179
+ .from(serviceBindings).where(eq(serviceBindings.id, end.id));
180
+ if (row === undefined || row.principal !== c.principal || row.revokedAt !== null || !same(row.authMac, end.macFrom) || !same(row.seal ?? null, end.sealFrom)) return { state: 'moved' };
181
+ const live = await tx.select({ id: credentials.id }).from(credentials)
182
+ .where(and(eq(credentials.principal, c.principal), eq(credentials.createdBy, `binding:${end.id}`), isNull(credentials.revokedAt), gt(credentials.expiresAt, new Date(now))));
183
+ const named = new Set(c.credentials.filter((x) => x.binding === end.id).map((x) => x.id));
184
+ if (live.some(({ id }) => !named.has(id))) return { state: 'moved' };
185
+ }
186
+ for (const x of c.credentials) {
187
+ const [row] = await tx.select({ createdBy: credentials.createdBy, revokedAt: credentials.revokedAt, authMac: credentials.authMac, seal: credentials.seal })
188
+ .from(credentials).where(eq(credentials.id, x.id));
189
+ if (row === undefined || row.createdBy !== `binding:${x.binding}` || !c.ends.some((end) => end.id === x.binding) || row.revokedAt !== null
190
+ || !same(row.authMac, x.macFrom) || !same(row.seal ?? null, x.sealFrom)) {
191
+ return { state: 'moved' };
192
+ }
193
+ }
194
+ const [changed] = await tx.select({ seq: auditLog.seq }).from(auditLog)
195
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'policy.change'), eq(auditLog.decision, 'allow'))).orderBy(desc(auditLog.seq)).limit(1);
196
+ if ((changed === undefined ? null : String(changed.seq)) !== c.policy.seq) return { state: 'policy' };
197
+ for (const { environmentId, slug } of c.policy.slugs) {
198
+ const [place] = await tx.select({ slug: environments.slug, project: projects.slug }).from(environments).innerJoin(projects, eq(projects.id, environments.projectId)).where(eq(environments.id, environmentId));
199
+ if ((place === undefined || place.slug.includes('~') || place.project.includes('~') ? null : place.slug) !== slug) return { state: 'policy' };
200
+ }
201
+ const [moved] = await tx.select({ seq: auditLog.seq }).from(auditLog)
202
+ .where(and(eq(auditLog.author, 'app'), eq(auditLog.subjectPrincipal, c.appKey), inArray(auditLog.action, ['key.reseal', 'key.rotate']))).limit(1);
203
+ if (moved !== undefined) return { state: 'retired' };
204
+ // ChangeChecksSeal: each row it ends its object's newest change's.
205
+ for (const end of c.ends) if (!same(await newest({ kind: 'binding', id: end.id }), end.sealFrom)) return { state: 'stale' };
206
+ for (const x of c.credentials) if (!same(await newest({ kind: 'credential', id: x.id }), x.sealFrom)) return { state: 'stale' };
207
+ if (m !== null) {
208
+ const b = m.row;
209
+ await tx.insert(serviceBindings).values({
210
+ id: b.id, authMac: m.authMac, principal: b.principal, generation: b.generation, profile: b.profile, issuer: b.issuer, jwksUri: b.jwksUri, jwks: b.jwks,
211
+ claims: b.claims, scope: b.scope, policy: b.policy, origin: b.origin, label: b.label, createdBy: b.createdBy, createdAt: new Date(b.createdAt), seal: m.seal,
212
+ });
213
+ }
214
+ const ended = new Date(c.revokedAt);
215
+ for (const end of c.ends) {
216
+ await tx.update(serviceBindings).set({ revokedAt: ended, revokedBy: c.revokedBy, authMac: end.authMac, seal: end.seal }).where(eq(serviceBindings.id, end.id));
217
+ }
218
+ for (const x of c.credentials) {
219
+ await tx.update(credentials).set({ revokedAt: ended, revokedBy: c.revokedBy, authMac: x.authMac, seal: x.seal }).where(eq(credentials.id, x.id));
220
+ }
221
+ let number = 0n;
222
+ for (const x of c.changes) {
223
+ number = await nextNumber(tx);
224
+ await tx.insert(logChanges).values({ number, at: c.at, requestId: x.requestId, object: x.object, actor: c.actor, payload: x.payload, seal: x.seal, mac: x.mac });
225
+ }
226
+ return { number: number.toString() };
227
+ }
228
+
229
+ /** One past the log's highest number, both tables' (store.ts's `changeOnSqlite` says why). */
230
+ async function nextNumber(tx: Queryable): Promise<bigint> {
231
+ const { logChanges, logUses } = tablesOf(tx);
232
+ const [{ last }] = await tx.select({ last: sql<number | string | null>`max(n)` }).from(
233
+ sql`(SELECT max(${logChanges.number}) AS n FROM ${logChanges} UNION ALL SELECT max(${logUses.number}) FROM ${logUses})`,
234
+ );
235
+ return BigInt(last ?? 0) + 1n;
236
+ }
237
+
238
+ function same(a: Uint8Array | null, b: Uint8Array | null): boolean {
239
+ return a === null || b === null ? a === b : Buffer.compare(Buffer.from(a), Buffer.from(b)) === 0;
240
+ }