@coffre/vault 0.14.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +6 -4
  2. package/dist/cloudflare.d.ts +343 -9
  3. package/dist/cloudflare.js +802 -6
  4. package/dist/{index-CIq4i_KT.d.ts → index-DQ-FKVF3.d.ts} +4 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/log-entry-BYWqj3O5.d.ts +364 -0
  8. package/dist/node.d.ts +182 -7
  9. package/dist/node.js +358 -16
  10. package/dist/{src-NM5NsYFs.js → src-DheG92EM.js} +9 -2
  11. package/dist/vault-kmtojYnW.js +14623 -0
  12. package/package.json +4 -5
  13. package/src/bulk.ts +89 -0
  14. package/src/changes.ts +478 -0
  15. package/src/checkpointer-node.ts +342 -0
  16. package/src/checkpointer-workers.ts +514 -0
  17. package/src/checkpointer.ts +1165 -0
  18. package/src/cloudflare.ts +207 -13
  19. package/src/config.ts +15 -3
  20. package/src/credentials.postgres.ts +78 -0
  21. package/src/credentials.sqlite.ts +240 -0
  22. package/src/credentials.ts +630 -0
  23. package/src/cutover.ts +275 -0
  24. package/src/decide.ts +437 -2
  25. package/src/inputs.ts +499 -0
  26. package/src/local.ts +20 -4
  27. package/src/log-checkpointer.ts +116 -0
  28. package/src/log-entry.ts +113 -0
  29. package/src/log-replay.ts +188 -0
  30. package/src/log-store.ts +695 -0
  31. package/src/node.ts +73 -9
  32. package/src/objects.ts +141 -0
  33. package/src/reads.ts +502 -0
  34. package/src/retention.ts +229 -0
  35. package/src/snapshot.postgres.ts +39 -9
  36. package/src/snapshot.sqlite.ts +28 -3
  37. package/src/snapshot.ts +72 -12
  38. package/src/store.ts +862 -37
  39. package/src/use.postgres.ts +111 -0
  40. package/src/use.sqlite.ts +542 -0
  41. package/src/use.ts +1060 -0
  42. package/src/vault.ts +1513 -240
  43. package/src/verifier.ts +136 -0
  44. package/src/witness-workers.ts +163 -0
  45. package/dist/vault-BBdXsny3.js +0 -5077
package/src/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
@@ -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
- /** At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
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
- if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) {
180
- throw new Error('bulkLimit needs a whole count of at least 1 and a window above 0 minutes');
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
+ }