@coffre/db 0.9.0 → 0.10.1

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 (55) hide show
  1. package/dist/connect.d.ts +1 -1
  2. package/dist/connect.js +1 -1
  3. package/dist/database-BadNeeN-.js +146 -0
  4. package/dist/{database-hd1hAll-.d.ts → database-DBtn3jqp.d.ts} +27 -3
  5. package/dist/dialect.d.ts +1 -1
  6. package/dist/grants.d.ts +2 -2
  7. package/dist/grants.js +26 -10
  8. package/dist/hyperdrive.d.ts +1 -1
  9. package/dist/index.d.ts +3 -3
  10. package/dist/index.js +2 -89
  11. package/dist/log.d.ts +26 -6
  12. package/dist/log.js +86 -21
  13. package/dist/migrations/postgres/0012_sensitive_values.sql +18 -0
  14. package/dist/migrations/postgres/0013_contributor_role.sql +5 -0
  15. package/dist/migrations/postgres/0014_custom_roles.sql +4 -0
  16. package/dist/migrations/postgres/meta/0012_snapshot.json +3004 -0
  17. package/dist/migrations/postgres/meta/0013_snapshot.json +3004 -0
  18. package/dist/migrations/postgres/meta/0014_snapshot.json +3000 -0
  19. package/dist/migrations/postgres/meta/_journal.json +21 -0
  20. package/dist/migrations/sqlite/0012_sensitive_values.sql +15 -0
  21. package/dist/migrations/sqlite/0013_contributor_role.sql +28 -0
  22. package/dist/migrations/sqlite/0014_custom_roles.sql +27 -0
  23. package/dist/migrations/sqlite/meta/0012_snapshot.json +2726 -0
  24. package/dist/migrations/sqlite/meta/0013_snapshot.json +2726 -0
  25. package/dist/migrations/sqlite/meta/0014_snapshot.json +2722 -0
  26. package/dist/migrations/sqlite/meta/_journal.json +21 -0
  27. package/dist/portable.d.ts +2 -2
  28. package/dist/{schema-CCYKTG-E.d.ts → schema-NBVA9q-s.d.ts} +17 -0
  29. package/dist/schema-sqlite.d.ts +17 -0
  30. package/dist/schema-sqlite.js +3 -6
  31. package/dist/schema-version.d.ts +1 -1
  32. package/dist/schema-version.js +42 -0
  33. package/dist/schema.d.ts +1 -1
  34. package/dist/schema.js +3 -7
  35. package/package.json +2 -2
  36. package/src/database.ts +35 -3
  37. package/src/grants.ts +32 -18
  38. package/src/log.ts +90 -34
  39. package/src/migrations/postgres/0012_sensitive_values.sql +18 -0
  40. package/src/migrations/postgres/0013_contributor_role.sql +5 -0
  41. package/src/migrations/postgres/0014_custom_roles.sql +4 -0
  42. package/src/migrations/postgres/meta/0012_snapshot.json +3004 -0
  43. package/src/migrations/postgres/meta/0013_snapshot.json +3004 -0
  44. package/src/migrations/postgres/meta/0014_snapshot.json +3000 -0
  45. package/src/migrations/postgres/meta/_journal.json +21 -0
  46. package/src/migrations/sqlite/0012_sensitive_values.sql +15 -0
  47. package/src/migrations/sqlite/0013_contributor_role.sql +28 -0
  48. package/src/migrations/sqlite/0014_custom_roles.sql +27 -0
  49. package/src/migrations/sqlite/meta/0012_snapshot.json +2726 -0
  50. package/src/migrations/sqlite/meta/0013_snapshot.json +2726 -0
  51. package/src/migrations/sqlite/meta/0014_snapshot.json +2722 -0
  52. package/src/migrations/sqlite/meta/_journal.json +21 -0
  53. package/src/prepared.ts +60 -0
  54. package/src/schema.sqlite.ts +8 -9
  55. package/src/schema.ts +8 -10
@@ -85,6 +85,27 @@
85
85
  "when": 1792317461299,
86
86
  "tag": "0011_plain_values",
87
87
  "breakpoints": true
88
+ },
89
+ {
90
+ "idx": 12,
91
+ "version": "6",
92
+ "when": 1792340920098,
93
+ "tag": "0012_sensitive_values",
94
+ "breakpoints": true
95
+ },
96
+ {
97
+ "idx": 13,
98
+ "version": "6",
99
+ "when": 1792343374795,
100
+ "tag": "0013_contributor_role",
101
+ "breakpoints": true
102
+ },
103
+ {
104
+ "idx": 14,
105
+ "version": "6",
106
+ "when": 1792345777255,
107
+ "tag": "0014_custom_roles",
108
+ "breakpoints": true
88
109
  }
89
110
  ]
90
111
  }
@@ -1,5 +1,5 @@
1
- import { m as schema_d_exports } from "./schema-CCYKTG-E.js";
2
- import { t as Database } from "./database-hd1hAll-.js";
1
+ import { m as schema_d_exports } from "./schema-NBVA9q-s.js";
2
+ import { t as Database } from "./database-DBtn3jqp.js";
3
3
  import { t as schema_sqlite_d_exports } from "./schema-sqlite.js";
4
4
  import { LibSQLDatabase } from "drizzle-orm/libsql";
5
5
  //#region src/portable.d.ts
@@ -731,6 +731,23 @@ declare const secretVersions: import("drizzle-orm/pg-core").PgTableWithColumns<{
731
731
  identity: undefined;
732
732
  generated: undefined;
733
733
  }, {}, {}>;
734
+ sensitive: import("drizzle-orm/pg-core").PgColumn<{
735
+ name: "sensitive";
736
+ tableName: "secret_versions";
737
+ dataType: "boolean";
738
+ columnType: "PgBoolean";
739
+ data: boolean;
740
+ driverParam: boolean;
741
+ notNull: true;
742
+ hasDefault: true;
743
+ isPrimaryKey: false;
744
+ isAutoincrement: false;
745
+ hasRuntimeDefault: false;
746
+ enumValues: undefined;
747
+ baseColumn: never;
748
+ identity: undefined;
749
+ generated: undefined;
750
+ }, {}, {}>;
734
751
  };
735
752
  dialect: "pg";
736
753
  }>;
@@ -775,6 +775,23 @@ export declare const secretVersions: import("drizzle-orm/sqlite-core").SQLiteTab
775
775
  }, {}, {
776
776
  length: undefined;
777
777
  }>;
778
+ sensitive: import("drizzle-orm/sqlite-core").SQLiteColumn<{
779
+ name: string;
780
+ tableName: "secret_versions";
781
+ dataType: "boolean";
782
+ columnType: "SQLiteBoolean";
783
+ data: boolean;
784
+ driverParam: number;
785
+ notNull: true;
786
+ hasDefault: true;
787
+ isPrimaryKey: false;
788
+ isAutoincrement: false;
789
+ hasRuntimeDefault: false;
790
+ enumValues: undefined;
791
+ baseColumn: never;
792
+ identity: undefined;
793
+ generated: undefined;
794
+ }, {}, {}>;
778
795
  };
779
796
  dialect: "sqlite";
780
797
  }>;
@@ -1,7 +1,6 @@
1
1
  import { n as relationsOf, t as ACTIVE_SUBJECT } from "./relations-BfR3JjT1.js";
2
2
  import { asPostgres } from "./portable.js";
3
3
  import { sql } from "drizzle-orm";
4
- import { ROLE_NAMES, assignableToEnvironment } from "@coffre/core/access";
5
4
  import { blob, check, customType, foreignKey, index, integer, primaryKey, sqliteTable, text, unique, uniqueIndex } from "drizzle-orm/sqlite-core";
6
5
  //#region src/schema.sqlite.ts
7
6
  /**
@@ -125,7 +124,8 @@ const secretVersions = sqliteTable("secret_versions", {
125
124
  recipe: text(),
126
125
  pairId: text("pair_id"),
127
126
  pairHalf: text("pair_half"),
128
- plaintext: text()
127
+ plaintext: text(),
128
+ sensitive: flag("sensitive").notNull().default(false)
129
129
  }, (table) => [
130
130
  unique("secret_versions_secret_id_version_key").on(table.secretId, table.version),
131
131
  check("secret_versions_version_check", sql`${table.version} > 0`),
@@ -157,8 +157,6 @@ const dismissedKeys = sqliteTable("dismissed_keys", {
157
157
  foreignColumns: [environments.id]
158
158
  }).onDelete("restrict")
159
159
  ]);
160
- const ROLES = sql.raw(ROLE_NAMES.map((role) => `'${role}'`).join(", "));
161
- const ENVIRONMENT_ROLES = sql.raw(ROLE_NAMES.filter(assignableToEnvironment).map((role) => `'${role}'`).join(", "));
162
160
  const vaultMembers = sqliteTable("vault_members", {
163
161
  principal: text().primaryKey(),
164
162
  status: text().notNull(),
@@ -195,8 +193,7 @@ const vaultGrants = sqliteTable("vault_grants", {
195
193
  }, (table) => [
196
194
  check("vault_grants_one_place", sql`((${table.projectId} IS NULL) <> (${table.environmentId} IS NULL) AND ${table.environmentSlug} IS NULL) OR (${table.projectId} IS NULL AND ${table.environmentId} IS NULL)`),
197
195
  check("vault_grants_environment_slug_check", sql`${table.environmentSlug} IS NULL OR (${isSlug(table.environmentSlug, 63)})`),
198
- check("vault_grants_role_check", sql`${table.role} IN (${ROLES})`),
199
- check("vault_grants_environment_role_check", sql`(${table.environmentId} IS NULL AND ${table.environmentSlug} IS NULL) OR ${table.role} IN (${ENVIRONMENT_ROLES})`),
196
+ check("vault_grants_role_check", sql`length(${table.role}) BETWEEN 2 AND 32 AND ${table.role} GLOB '[a-z]*' AND ${table.role} NOT GLOB '*[^a-z0-9-]*'`),
200
197
  unique("vault_grants_on_project").on(table.principal, table.projectId),
201
198
  unique("vault_grants_on_environment").on(table.principal, table.environmentId),
202
199
  unique("vault_grants_on_environment_slug").on(table.principal, table.environmentSlug),
@@ -1,4 +1,4 @@
1
- import { i as Queryable } from "./database-hd1hAll-.js";
1
+ import { i as Queryable } from "./database-DBtn3jqp.js";
2
2
  import { Engine } from "./dialect.js";
3
3
  //#region src/schema-version.d.ts
4
4
  /**
@@ -92,6 +92,27 @@ const KNOWN_MIGRATIONS = {
92
92
  "when": 1792317460399,
93
93
  "tag": "0011_plain_values",
94
94
  "breakpoints": true
95
+ },
96
+ {
97
+ "idx": 12,
98
+ "version": "7",
99
+ "when": 1792340919452,
100
+ "tag": "0012_sensitive_values",
101
+ "breakpoints": true
102
+ },
103
+ {
104
+ "idx": 13,
105
+ "version": "7",
106
+ "when": 1792343374226,
107
+ "tag": "0013_contributor_role",
108
+ "breakpoints": true
109
+ },
110
+ {
111
+ "idx": 14,
112
+ "version": "7",
113
+ "when": 1792345776262,
114
+ "tag": "0014_custom_roles",
115
+ "breakpoints": true
95
116
  }
96
117
  ].map((entry) => entry.tag),
97
118
  sqlite: [
@@ -178,6 +199,27 @@ const KNOWN_MIGRATIONS = {
178
199
  "when": 1792317461299,
179
200
  "tag": "0011_plain_values",
180
201
  "breakpoints": true
202
+ },
203
+ {
204
+ "idx": 12,
205
+ "version": "6",
206
+ "when": 1792340920098,
207
+ "tag": "0012_sensitive_values",
208
+ "breakpoints": true
209
+ },
210
+ {
211
+ "idx": 13,
212
+ "version": "6",
213
+ "when": 1792343374795,
214
+ "tag": "0013_contributor_role",
215
+ "breakpoints": true
216
+ },
217
+ {
218
+ "idx": 14,
219
+ "version": "6",
220
+ "when": 1792345777255,
221
+ "tag": "0014_custom_roles",
222
+ "breakpoints": true
181
223
  }
182
224
  ].map((entry) => entry.tag)
183
225
  };
package/dist/schema.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { _ as secrets, a as deviceAuthorizations, b as vaultGrants, c as environmentsRelations, d as mcpConnections, f as oauthClients, g as secretVersions, h as secretReferences, i as credentials, l as identities, n as auditLog, o as dismissedKeys, p as projects, r as consumedTokens, s as environments, t as auditChainHead, u as mcpApprovals, v as secretsRelations, x as vaultMembers, y as serviceBindings } from "./schema-CCYKTG-E.js";
1
+ import { _ as secrets, a as deviceAuthorizations, b as vaultGrants, c as environmentsRelations, d as mcpConnections, f as oauthClients, g as secretVersions, h as secretReferences, i as credentials, l as identities, n as auditLog, o as dismissedKeys, p as projects, r as consumedTokens, s as environments, t as auditChainHead, u as mcpApprovals, v as secretsRelations, x as vaultMembers, y as serviceBindings } from "./schema-NBVA9q-s.js";
2
2
  export { auditChainHead, auditLog, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, vaultGrants, vaultMembers };
package/dist/schema.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
2
2
  import { n as relationsOf, t as ACTIVE_SUBJECT } from "./relations-BfR3JjT1.js";
3
3
  import { sql } from "drizzle-orm";
4
- import { ROLE_NAMES, assignableToEnvironment } from "@coffre/core/access";
5
4
  import { bigint, boolean, check, customType, foreignKey, index, integer, pgTable, primaryKey, text, timestamp, unique, uniqueIndex, uuid } from "drizzle-orm/pg-core";
6
5
  //#region src/schema.ts
7
6
  var schema_exports = /* @__PURE__ */ __exportAll({
@@ -130,7 +129,8 @@ const secretVersions = pgTable("secret_versions", {
130
129
  recipe: text(),
131
130
  pairId: uuid("pair_id"),
132
131
  pairHalf: text("pair_half"),
133
- plaintext: text()
132
+ plaintext: text(),
133
+ sensitive: boolean().notNull().default(false)
134
134
  }, (table) => [
135
135
  unique("secret_versions_secret_id_version_key").on(table.secretId, table.version),
136
136
  check("secret_versions_version_check", sql`${table.version} > 0`),
@@ -169,9 +169,6 @@ const dismissedKeys = pgTable("dismissed_keys", {
169
169
  foreignColumns: [environments.id]
170
170
  }).onDelete("restrict")
171
171
  ]);
172
- /** The role catalogue, as SQL: every role, and those an environment may be granted. */
173
- const ROLES = sql.raw(ROLE_NAMES.map((role) => `'${role}'`).join(", "));
174
- const ENVIRONMENT_ROLES = sql.raw(ROLE_NAMES.filter(assignableToEnvironment).map((role) => `'${role}'`).join(", "));
175
172
  /**
176
173
  * The vault's member directory: everyone it has admitted, written by the
177
174
  * vault alone, read by both. A principal with no row is no member.
@@ -220,8 +217,7 @@ const vaultGrants = pgTable("vault_grants", {
220
217
  }, (table) => [
221
218
  check("vault_grants_one_place", sql`((${table.projectId} IS NULL) <> (${table.environmentId} IS NULL) AND ${table.environmentSlug} IS NULL) OR (${table.projectId} IS NULL AND ${table.environmentId} IS NULL)`),
222
219
  check("vault_grants_environment_slug_check", sql`${table.environmentSlug} IS NULL OR ${table.environmentSlug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
223
- check("vault_grants_role_check", sql`${table.role} IN (${ROLES})`),
224
- check("vault_grants_environment_role_check", sql`(${table.environmentId} IS NULL AND ${table.environmentSlug} IS NULL) OR ${table.role} IN (${ENVIRONMENT_ROLES})`),
220
+ check("vault_grants_role_check", sql`${table.role} ~ '^[a-z][a-z0-9-]{1,31}$'`),
225
221
  unique("vault_grants_on_project").on(table.principal, table.projectId),
226
222
  unique("vault_grants_on_environment").on(table.principal, table.environmentId),
227
223
  unique("vault_grants_on_environment_slug").on(table.principal, table.environmentSlug),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/db",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "coffre's database: the schema, its migrations and the connections, on Postgres or SQLite.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -79,7 +79,7 @@
79
79
  "drizzle-orm": "0.45.2",
80
80
  "pg": "8.16.3",
81
81
  "pg-connection-string": "2.14.0",
82
- "@coffre/core": "0.9.0"
82
+ "@coffre/core": "0.10.1"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "26.1.1",
package/src/database.ts CHANGED
@@ -1,7 +1,16 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
 
3
- import { getTableName, is, Table } from 'drizzle-orm';
4
- import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
3
+ import {
4
+ createTableRelationsHelpers,
5
+ extractTablesRelationalConfig,
6
+ getTableName,
7
+ is,
8
+ Table,
9
+ type ExtractTablesWithRelations,
10
+ type RelationalSchemaConfig,
11
+ } from 'drizzle-orm';
12
+ import { NodePgDatabase, NodePgDriver } from 'drizzle-orm/node-postgres';
13
+ import { PgDialect } from 'drizzle-orm/pg-core';
5
14
  import type pg from 'pg';
6
15
 
7
16
  import { trackCommits } from './commits.ts';
@@ -74,8 +83,29 @@ function brokenRule(error: unknown): QueryOutsideTransaction | undefined {
74
83
  return undefined;
75
84
  }
76
85
 
86
+ /**
87
+ * What Drizzle makes of the schema, which `drizzle(pool, { schema })` works
88
+ * out again from every table at each call: on Workers, a database is made
89
+ * per call, and this was a fifth of a vault call's CPU. The schema never
90
+ * changes, so it is worked out once per process or isolate, a settled value.
91
+ */
92
+ type Relational = RelationalSchemaConfig<ExtractTablesWithRelations<typeof schema>>;
93
+ let relational: Relational | undefined;
94
+
95
+ function relationalSchema(): Relational {
96
+ if (relational === undefined) {
97
+ const { tables, tableNamesMap } = extractTablesRelationalConfig(schema, createTableRelationsHelpers);
98
+ // Drizzle's own construct passes the same untyped tables on as these.
99
+ relational = { fullSchema: schema, schema: tables as Relational['schema'], tableNamesMap };
100
+ }
101
+ return relational;
102
+ }
103
+
77
104
  export function createDatabase(pool: PoolLike): Database {
78
- const db = drizzle(guarded(pool) as pg.Pool, { schema });
105
+ // What `drizzle(pool, { schema })` builds, but for the schema's config, made once.
106
+ const dialect = new PgDialect();
107
+ const config = relationalSchema();
108
+ const db: Database = new NodePgDatabase(dialect, new NodePgDriver(guarded(pool) as pg.Pool, dialect).createSession(config), config);
79
109
  // Each transaction's work runs knowing which one it is in, until that work is done. Not until
80
110
  // the COMMIT comes back: the transaction waits on nothing then, so what the work left running
81
111
  // may ask for a query, which waits for the transaction to end rather than for itself.
@@ -132,3 +162,5 @@ export function own<T extends Table>(db: Queryable, table: T): T {
132
162
  if (found === undefined) throw new Error(`no table ${getTableName(table)} in this database`);
133
163
  return found as T;
134
164
  }
165
+
166
+ export { prepared } from './prepared.ts';
package/src/grants.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  type Conversion,
10
10
  type ConversionInput,
11
11
  type InstanceRole,
12
+ type BuiltinRole,
12
13
  type Role,
13
14
  type Scope,
14
15
  } from '@coffre/core/access';
@@ -17,8 +18,9 @@ import { ACCESS_ACTIONS } from '@coffre/core/vault';
17
18
  import { and, asc, desc, eq, gt, inArray, isNull, like, lt, or, sql, type SQL } from 'drizzle-orm';
18
19
 
19
20
  import { tablesOf, type Queryable } from './database.ts';
21
+ import { prepared } from './prepared.ts';
20
22
  import { tombstone, truth } from './dialect.ts';
21
- import { entryColumns, LOOK_BACK, newestAuthentic, newestVaultEntries, type Authenticity, type Newest } from './log.ts';
23
+ import { entryColumns, newestAuthentic, newestVaultEntries, type Authenticity, type Newest } from './log.ts';
22
24
 
23
25
  /**
24
26
  * One grant, as `vault_grants` holds it: on a project (`environmentId`
@@ -45,20 +47,24 @@ export async function readGrants(
45
47
  db: Queryable,
46
48
  filter: { principal?: string; liveAt?: number } = {},
47
49
  ): Promise<GrantRow[]> {
50
+ // A member's grants, which every check reads, built once (`prepared`); any other filter, as asked.
51
+ const rows = filter.principal !== undefined && filter.liveAt === undefined
52
+ ? await grantsOf(db, { principal: filter.principal })
53
+ : await grantRows(db, filter.principal === undefined ? undefined : eq(tablesOf(db).vaultGrants.principal, filter.principal), filter.liveAt);
54
+ return rows.map(({ grant, environmentProjectId }) => ({ ...grant, projectId: grant.projectId ?? environmentProjectId }));
55
+ }
56
+
57
+ function grantRows(db: Queryable, principal: SQL | undefined, liveAt?: number) {
48
58
  const { vaultGrants, environments } = tablesOf(db);
49
- const rows = await db
59
+ return db
50
60
  .select({ grant: vaultGrants, environmentProjectId: environments.projectId })
51
61
  .from(vaultGrants)
52
62
  .leftJoin(environments, eq(environments.id, vaultGrants.environmentId))
53
- .where(
54
- and(
55
- filter.principal === undefined ? undefined : eq(vaultGrants.principal, filter.principal),
56
- filter.liveAt === undefined ? undefined : or(isNull(vaultGrants.expiresAt), gt(vaultGrants.expiresAt, filter.liveAt)),
57
- ),
58
- );
59
- return rows.map(({ grant, environmentProjectId }) => ({ ...grant, projectId: grant.projectId ?? environmentProjectId }));
63
+ .where(and(principal, liveAt === undefined ? undefined : or(isNull(vaultGrants.expiresAt), gt(vaultGrants.expiresAt, liveAt))));
60
64
  }
61
65
 
66
+ const grantsOf = prepared((db) => grantRows(db, eq(tablesOf(db).vaultGrants.principal, sql.placeholder('principal'))));
67
+
62
68
  /** Add a grant; an environment's row names only the environment. */
63
69
  export async function insertGrant(db: Queryable, grant: GrantRow): Promise<void> {
64
70
  const { vaultGrants } = tablesOf(db);
@@ -129,10 +135,10 @@ export function everyProjectConversion(
129
135
  role: person ? role : 'member',
130
136
  everyProject: live
131
137
  .filter((grant) => grant.projectId === null && grant.environmentId === null)
132
- .map((grant) => ({ environmentSlug: grant.environmentSlug, role: grant.role as Role, expiresAt: grant.expiresAt })),
138
+ .map((grant) => ({ environmentSlug: grant.environmentSlug, role: grant.role as BuiltinRole, expiresAt: grant.expiresAt })),
133
139
  grants: live
134
140
  .filter((grant) => grant.projectId !== null)
135
- .map((grant) => ({ projectId: grant.projectId, environmentId: grant.environmentId, role: grant.role as Role, expiresAt: grant.expiresAt })),
141
+ .map((grant) => ({ projectId: grant.projectId, environmentId: grant.environmentId, role: grant.role as BuiltinRole, expiresAt: grant.expiresAt })),
136
142
  projects,
137
143
  });
138
144
  }
@@ -288,35 +294,43 @@ export async function reportStands(db: Queryable, principal: string, check: Auth
288
294
  const access = await newestAccess(db, principal, check);
289
295
  if (access === 'unreached') return true;
290
296
  const after = typeof access === 'string' ? -1n : access.seq;
291
- const report = await newestAuthentic((before) => tamperReportsAbout(db, principal, LOOK_BACK.page, after, before), check);
297
+ const report = await newestAuthentic((limit, before) => tamperReportsAbout(db, principal, limit, after, before), check);
292
298
  return report === 'unreached' || typeof report !== 'string';
293
299
  }
294
300
 
295
301
  /** The newest access entry about `principal` that `check` finds the vault's own. */
296
302
  export function newestAccess(db: Queryable, principal: string, check: Authenticity): Promise<Newest> {
297
- return newestAuthentic((before) => accessEntriesAbout(db, principal, LOOK_BACK.page, before), check);
303
+ return newestAuthentic((limit, before) => accessEntriesAbout(db, principal, limit, before), check);
298
304
  }
299
305
 
300
306
  /** Up to `limit` of the vault's allowed access entries about `principal` before `before` (the newest when undefined), newest first. */
301
307
  export async function accessEntriesAbout(db: Queryable, principal: string, limit: number, before?: bigint): Promise<StoredEntry[]> {
308
+ const rows = before === undefined ? await newestAccessEntries(db, { principal, limit }) : await accessEntriesBefore(db, { principal, limit, before });
309
+ return rows as StoredEntry[];
310
+ }
311
+
312
+ function accessEntries(db: Queryable, before?: SQL) {
302
313
  const { auditLog } = tablesOf(db);
303
- const rows = await db
314
+ return db
304
315
  .select(entryColumns(db))
305
316
  .from(auditLog)
306
317
  .where(
307
318
  and(
308
319
  eq(auditLog.author, 'vault'),
309
- eq(auditLog.subjectPrincipal, principal),
320
+ eq(auditLog.subjectPrincipal, sql.placeholder('principal')),
310
321
  inArray(auditLog.action, [...ACCESS_ACTIONS]),
311
322
  eq(auditLog.decision, 'allow'),
312
- before === undefined ? undefined : lt(auditLog.seq, before),
323
+ before,
313
324
  ),
314
325
  )
315
326
  .orderBy(desc(auditLog.seq))
316
- .limit(limit);
317
- return rows as StoredEntry[];
327
+ .limit(sql.placeholder('limit'));
318
328
  }
319
329
 
330
+ // What every check of a member reads first, built once (`prepared`).
331
+ const newestAccessEntries = prepared((db) => accessEntries(db));
332
+ const accessEntriesBefore = prepared((db) => accessEntries(db, lt(tablesOf(db).auditLog.seq, sql.placeholder('before'))));
333
+
320
334
  /**
321
335
  * Up to `limit` of the vault's reports that `principal`'s row was changed
322
336
  * around it (`TAMPER_CODES`), after `after` and before `before` (the newest
package/src/log.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { GENESIS_HASH, sealEntry, type LogFields, type LogKey, type StoredEntry } from '@coffre/core/audit';
2
- import { and, asc, desc, eq, lt, sql, type SQL } from 'drizzle-orm';
2
+ import { and, asc, count, desc, eq, gt, gte, lt, lte, sql, type SQL } from 'drizzle-orm';
3
3
 
4
4
  import { afterCommit } from './commits.ts';
5
5
  import { tablesOf, type Queryable, type Transaction } from './database.ts';
6
+ import { prepared } from './prepared.ts';
6
7
  import { clockMillis, forUpdate } from './dialect.ts';
7
8
 
8
9
  /**
@@ -99,18 +100,19 @@ export function forgetLogHeads(): void {
99
100
  * again in the same transaction is free.
100
101
  */
101
102
  export async function lockLogHead(tx: Transaction): Promise<{ nextSeq: bigint; headHash: Buffer }> {
102
- const { auditChainHead } = tablesOf(tx);
103
- const [head] = await forUpdate(
104
- tx,
105
- tx
106
- .select({ nextSeq: auditChainHead.nextSeq, headHash: auditChainHead.headHash })
107
- .from(auditChainHead)
108
- .where(eq(auditChainHead.onlyRow, true)),
109
- );
103
+ const [head] = await lockedHead(tx);
110
104
  if (head === undefined) throw new Error('audit_chain_head is missing; refusing to write an unchained entry');
111
105
  return head;
112
106
  }
113
107
 
108
+ const lockedHead = prepared((db) => {
109
+ const { auditChainHead } = tablesOf(db);
110
+ return forUpdate(
111
+ db,
112
+ db.select({ nextSeq: auditChainHead.nextSeq, headHash: auditChainHead.headHash }).from(auditChainHead).where(eq(auditChainHead.onlyRow, true)),
113
+ );
114
+ });
115
+
114
116
  /**
115
117
  * Append `entries` under `key`. `check`, when given, runs once the head is
116
118
  * locked and before anything is written: a writer's own condition on the
@@ -129,12 +131,8 @@ export async function appendEntries(
129
131
  // Every append queues here, if its transaction has not already.
130
132
  const head = await lockLogHead(tx);
131
133
  await check?.(tx);
132
- const [{ now }] = await tx.select({ now: clockMillis(tx) }).from(auditChainHead);
133
- const [last] = await tx
134
- .select({ seq: auditLog.seq, hash: auditLog.hash })
135
- .from(auditLog)
136
- .orderBy(desc(auditLog.seq))
137
- .limit(1);
134
+ const [{ now }] = await clockNow(tx);
135
+ const [last] = await lastEntry(tx);
138
136
 
139
137
  const expected = last === undefined ? { nextSeq: 0n, headHash: GENESIS_HASH } : { nextSeq: last.seq + 1n, headHash: last.hash };
140
138
  if (head.nextSeq !== expected.nextSeq || !head.headHash.equals(expected.headHash)) {
@@ -188,6 +186,13 @@ export async function appendEntries(
188
186
  return { seqStart: head.nextSeq, ...written, occurredAt: now };
189
187
  }
190
188
 
189
+ const clockNow = prepared((db) => db.select({ now: clockMillis(db) }).from(tablesOf(db).auditChainHead));
190
+
191
+ const lastEntry = prepared((db) => {
192
+ const { auditLog } = tablesOf(db);
193
+ return db.select({ seq: auditLog.seq, hash: auditLog.hash }).from(auditLog).orderBy(desc(auditLog.seq)).limit(1);
194
+ });
195
+
191
196
  // --- reading the vault's entries back ---------------------------------------------
192
197
 
193
198
  /** An entry's columns, as the chain's codec reads them (`StoredEntry`). */
@@ -257,6 +262,33 @@ export async function withinBytes(
257
262
  return { from: sizes[0]!.seq, to: sizes[count - 1]!.seq, cut: count < sizes.length, count };
258
263
  }
259
264
 
265
+ /**
266
+ * The first entry missing from the log up to `seq`, and the one found in
267
+ * its place, or null when every one is there. Seqs are the primary key, so
268
+ * a stretch is whole exactly when its count is its length: one pass over
269
+ * the key's index, never the entries. Only a log with a gap pays for more,
270
+ * a count per halving to find where.
271
+ *
272
+ * entries 0..9, 4 cut: { expected: 4n, found: 5n }
273
+ */
274
+ export async function missingEntry(db: Queryable, seq: bigint): Promise<{ expected: bigint; found: bigint | null } | null> {
275
+ const { auditLog } = tablesOf(db);
276
+ const whole = async (to: bigint) => {
277
+ const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, to)));
278
+ return BigInt(n) === to + 1n;
279
+ };
280
+ if (seq < 0n || (await whole(seq))) return null;
281
+ // The first stretch from 0 that is not whole ends at the missing entry.
282
+ let [low, high] = [0n, seq];
283
+ while (low < high) {
284
+ const middle = (low + high) / 2n;
285
+ if (await whole(middle)) low = middle + 1n;
286
+ else high = middle;
287
+ }
288
+ const [next] = await db.select({ seq: auditLog.seq }).from(auditLog).where(gt(auditLog.seq, low)).orderBy(asc(auditLog.seq)).limit(1);
289
+ return { expected: low, found: (next?.seq as bigint | undefined) ?? null };
290
+ }
291
+
260
292
  /**
261
293
  * How a reader tells the vault's own entries from those forged in its name:
262
294
  * by their MAC, which only the vault holds the key to check. Each it finds
@@ -266,23 +298,43 @@ export type Authenticity = { authentic: (entry: StoredEntry) => boolean; forged:
266
298
 
267
299
  /** Up to `limit` of the entries in the vault's name, allowed, of one `action`, before `before` (the newest when undefined), newest first. */
268
300
  export async function newestVaultEntries(db: Queryable, action: string, limit: number, before?: bigint): Promise<StoredEntry[]> {
301
+ const rows = before === undefined ? await newestEntries(db, { action, limit }) : await entriesBefore(db, { action, limit, before });
302
+ return rows as StoredEntry[];
303
+ }
304
+
305
+ function vaultEntries(db: Queryable, before?: SQL) {
269
306
  const { auditLog } = tablesOf(db);
270
- const rows = await db
307
+ return db
271
308
  .select(entryColumns(db))
272
309
  .from(auditLog)
273
- .where(
274
- and(
275
- eq(auditLog.author, 'vault'),
276
- eq(auditLog.action, action),
277
- eq(auditLog.decision, 'allow'),
278
- before === undefined ? undefined : lt(auditLog.seq, before),
279
- ),
280
- )
310
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, sql.placeholder('action')), eq(auditLog.decision, 'allow'), before))
281
311
  .orderBy(desc(auditLog.seq))
282
- .limit(limit);
283
- return rows as StoredEntry[];
312
+ .limit(sql.placeholder('limit'));
313
+ }
314
+
315
+ const newestEntries = prepared((db) => vaultEntries(db));
316
+ const entriesBefore = prepared((db) => vaultEntries(db, lt(tablesOf(db).auditLog.seq, sql.placeholder('before'))));
317
+
318
+ /**
319
+ * The number of the newest entry in the vault's name, allowed, of one
320
+ * `action`, or null for none: whether one was appended since, and nothing
321
+ * of what it says, so its MAC is not checked. One indexed read.
322
+ */
323
+ export async function newestVaultSeq(db: Queryable, action: string): Promise<bigint | null> {
324
+ const [row] = await newestSeq(db, { action });
325
+ return (row?.seq as bigint | undefined) ?? null;
284
326
  }
285
327
 
328
+ const newestSeq = prepared((db) => {
329
+ const { auditLog } = tablesOf(db);
330
+ return db
331
+ .select({ seq: auditLog.seq })
332
+ .from(auditLog)
333
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, sql.placeholder('action')), eq(auditLog.decision, 'allow')))
334
+ .orderBy(desc(auditLog.seq))
335
+ .limit(1);
336
+ });
337
+
286
338
  /** How many entries in its name the vault reads back, a page at a time, for its own newest of a kind (`newestAuthentic`). */
287
339
  export const LOOK_BACK = { page: 32, pages: 32 };
288
340
 
@@ -291,20 +343,24 @@ export type Newest = StoredEntry | 'none' | 'forged' | 'unreached';
291
343
 
292
344
  /**
293
345
  * The newest of the entries `page` reads, newest first and before a seq,
294
- * that `check` finds authentic, read back a page at a time: 'none' when
295
- * there are none at all, 'forged' when every one is in the vault's name but
296
- * not its own, and 'unreached' once it has read LOOK_BACK such entries
297
- * (1024), whether or not more are left.
346
+ * that `check` finds authentic: the newest alone first, which it is unless
347
+ * entries were forged in the vault's name since, then back a page at a
348
+ * time. 'none' when there are none at all, 'forged' when every one is in
349
+ * the vault's name but not its own, and 'unreached' once it has read
350
+ * LOOK_BACK such entries (1024), whether or not more are left.
298
351
  */
299
- export async function newestAuthentic(page: (before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest> {
352
+ export async function newestAuthentic(page: (limit: number, before: bigint | undefined) => Promise<StoredEntry[]>, check: Authenticity): Promise<Newest> {
353
+ const most = LOOK_BACK.page * LOOK_BACK.pages;
354
+ let read = 0;
300
355
  let before: bigint | undefined;
301
- for (let read = 0; read < LOOK_BACK.pages; read += 1) {
302
- const entries = await page(before);
356
+ for (let limit = 1; read < most; limit = Math.min(LOOK_BACK.page, most - read)) {
357
+ const entries = await page(limit, before);
303
358
  for (const entry of entries) {
304
359
  if (check.authentic(entry)) return entry;
305
360
  check.forged(entry);
306
361
  }
307
- if (entries.length < LOOK_BACK.page) return before === undefined && entries.length === 0 ? 'none' : 'forged';
362
+ read += entries.length;
363
+ if (entries.length < limit) return read === 0 ? 'none' : 'forged';
308
364
  before = entries.at(-1)!.seq;
309
365
  }
310
366
  return 'unreached';
@@ -0,0 +1,18 @@
1
+ -- Sensitive values (0.10): a version never handed back to a person, which the vault's wrap of its data key binds. Defaults to false, so the release before writes on as it did.
2
+ ALTER TABLE "secret_versions" ADD COLUMN "sensitive" boolean DEFAULT false NOT NULL;--> statement-breakpoint
3
+ -- A version is still only ever erased: its sensitivity with the rest of it.
4
+ CREATE OR REPLACE FUNCTION secret_versions_erase_only() RETURNS trigger LANGUAGE plpgsql AS $$
5
+ BEGIN
6
+ IF octet_length(NEW.ciphertext) <> 0 OR octet_length(NEW.wrapped_dek) <> 0 OR NEW.plaintext IS NOT NULL
7
+ OR (NEW.id, NEW.secret_id, NEW.version, NEW.envelope_version, NEW.iv, NEW.auth_tag,
8
+ NEW.kek_provider, NEW.kek_id, NEW.kek_version, NEW.created_at, NEW.created_by,
9
+ NEW.recipe, NEW.pair_id, NEW.pair_half, NEW.sensitive)
10
+ IS DISTINCT FROM
11
+ (OLD.id, OLD.secret_id, OLD.version, OLD.envelope_version, OLD.iv, OLD.auth_tag,
12
+ OLD.kek_provider, OLD.kek_id, OLD.kek_version, OLD.created_at, OLD.created_by,
13
+ OLD.recipe, OLD.pair_id, OLD.pair_half, OLD.sensitive) THEN
14
+ RAISE EXCEPTION 'a secret version is only ever erased' USING ERRCODE = 'insufficient_privilege';
15
+ END IF;
16
+ RETURN NEW;
17
+ END
18
+ $$;
@@ -0,0 +1,5 @@
1
+ -- The Contributor role (0.10): use and write, never read. The role checks widen to it, which the release before never grants.
2
+ ALTER TABLE "vault_grants" DROP CONSTRAINT "vault_grants_role_check";--> statement-breakpoint
3
+ ALTER TABLE "vault_grants" DROP CONSTRAINT "vault_grants_environment_role_check";--> statement-breakpoint
4
+ ALTER TABLE "vault_grants" ADD CONSTRAINT "vault_grants_role_check" CHECK ("vault_grants"."role" IN ('viewer', 'contributor', 'developer', 'maintainer', 'access-manager', 'auditor', 'owner'));--> statement-breakpoint
5
+ ALTER TABLE "vault_grants" ADD CONSTRAINT "vault_grants_environment_role_check" CHECK (("vault_grants"."environment_id" IS NULL AND "vault_grants"."environment_slug" IS NULL) OR "vault_grants"."role" IN ('viewer', 'contributor', 'developer', 'auditor'));
@@ -0,0 +1,4 @@
1
+ -- Roles an Owner makes (0.10): a grant names a role by its id, which the vault's policy defines; which of them an environment may be granted, the vault decides. The checks widen: the release before grants only the roles it ships.
2
+ ALTER TABLE "vault_grants" DROP CONSTRAINT "vault_grants_environment_role_check";--> statement-breakpoint
3
+ ALTER TABLE "vault_grants" DROP CONSTRAINT "vault_grants_role_check";--> statement-breakpoint
4
+ ALTER TABLE "vault_grants" ADD CONSTRAINT "vault_grants_role_check" CHECK ("vault_grants"."role" ~ '^[a-z][a-z0-9-]{1,31}$');