@coffre/db 0.3.0 → 0.4.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 (76) hide show
  1. package/dist/connect.d.ts +1 -1
  2. package/dist/{database-Cq_52uUo.d.ts → database-edkMkG02.d.ts} +1 -1
  3. package/dist/dialect.d.ts +10 -2
  4. package/dist/dialect.js +34 -1
  5. package/dist/grants.d.ts +32 -0
  6. package/dist/grants.js +27 -0
  7. package/dist/hyperdrive.d.ts +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/log.d.ts +1 -1
  10. package/dist/migrate.js +6 -1
  11. package/dist/migrations/postgres/0000_baseline.sql +174 -46
  12. package/dist/migrations/postgres/0001_mcp_approvals.sql +24 -0
  13. package/dist/migrations/postgres/meta/0000_snapshot.json +963 -185
  14. package/dist/migrations/postgres/meta/0001_snapshot.json +1171 -30
  15. package/dist/migrations/postgres/meta/_journal.json +3 -24
  16. package/dist/migrations/sqlite/0000_baseline.sql +142 -35
  17. package/dist/migrations/sqlite/0001_mcp_approvals.sql +19 -0
  18. package/dist/migrations/sqlite/meta/0000_snapshot.json +878 -235
  19. package/dist/migrations/sqlite/meta/0001_snapshot.json +1062 -57
  20. package/dist/migrations/sqlite/meta/_journal.json +3 -24
  21. package/dist/portable.d.ts +2 -2
  22. package/dist/{schema-Wt-p8AOh.d.ts → schema-BqCB3XFm.d.ts} +1153 -83
  23. package/dist/schema-sqlite.d.ts +1171 -60
  24. package/dist/schema-sqlite.js +173 -11
  25. package/dist/schema-version.d.ts +9 -13
  26. package/dist/schema-version.js +38 -90
  27. package/dist/schema.d.ts +2 -2
  28. package/dist/schema.js +208 -15
  29. package/package.json +7 -2
  30. package/src/baseline/postgres.sql +46 -10
  31. package/src/baseline/sqlite.sql +15 -0
  32. package/src/baseline.ts +7 -3
  33. package/src/dialect.ts +25 -1
  34. package/src/grants.ts +51 -0
  35. package/src/migrate.ts +5 -0
  36. package/src/migrations/postgres/0000_baseline.sql +174 -46
  37. package/src/migrations/postgres/0001_mcp_approvals.sql +24 -0
  38. package/src/migrations/postgres/meta/0000_snapshot.json +963 -185
  39. package/src/migrations/postgres/meta/0001_snapshot.json +1171 -30
  40. package/src/migrations/postgres/meta/_journal.json +3 -24
  41. package/src/migrations/sqlite/0000_baseline.sql +142 -35
  42. package/src/migrations/sqlite/0001_mcp_approvals.sql +19 -0
  43. package/src/migrations/sqlite/meta/0000_snapshot.json +878 -235
  44. package/src/migrations/sqlite/meta/0001_snapshot.json +1062 -57
  45. package/src/migrations/sqlite/meta/_journal.json +3 -24
  46. package/src/schema-version.ts +26 -18
  47. package/src/schema.sqlite.ts +223 -8
  48. package/src/schema.ts +263 -11
  49. package/dist/migrations/postgres/0001_remove_syncs.sql +0 -13
  50. package/dist/migrations/postgres/0002_service_bindings.sql +0 -28
  51. package/dist/migrations/postgres/0003_exchanges.sql +0 -10
  52. package/dist/migrations/postgres/0004_live_indexes.sql +0 -3
  53. package/dist/migrations/postgres/meta/0002_snapshot.json +0 -1870
  54. package/dist/migrations/postgres/meta/0003_snapshot.json +0 -1917
  55. package/dist/migrations/postgres/meta/0004_snapshot.json +0 -2006
  56. package/dist/migrations/sqlite/0001_remove_syncs.sql +0 -13
  57. package/dist/migrations/sqlite/0002_service_bindings.sql +0 -23
  58. package/dist/migrations/sqlite/0003_exchanges.sql +0 -7
  59. package/dist/migrations/sqlite/0004_live_indexes.sql +0 -3
  60. package/dist/migrations/sqlite/meta/0002_snapshot.json +0 -1736
  61. package/dist/migrations/sqlite/meta/0003_snapshot.json +0 -1781
  62. package/dist/migrations/sqlite/meta/0004_snapshot.json +0 -1811
  63. package/src/migrations/postgres/0001_remove_syncs.sql +0 -13
  64. package/src/migrations/postgres/0002_service_bindings.sql +0 -28
  65. package/src/migrations/postgres/0003_exchanges.sql +0 -10
  66. package/src/migrations/postgres/0004_live_indexes.sql +0 -3
  67. package/src/migrations/postgres/meta/0002_snapshot.json +0 -1870
  68. package/src/migrations/postgres/meta/0003_snapshot.json +0 -1917
  69. package/src/migrations/postgres/meta/0004_snapshot.json +0 -2006
  70. package/src/migrations/sqlite/0001_remove_syncs.sql +0 -13
  71. package/src/migrations/sqlite/0002_service_bindings.sql +0 -23
  72. package/src/migrations/sqlite/0003_exchanges.sql +0 -7
  73. package/src/migrations/sqlite/0004_live_indexes.sql +0 -3
  74. package/src/migrations/sqlite/meta/0002_snapshot.json +0 -1736
  75. package/src/migrations/sqlite/meta/0003_snapshot.json +0 -1781
  76. package/src/migrations/sqlite/meta/0004_snapshot.json +0 -1811
package/dist/schema.js CHANGED
@@ -2,7 +2,7 @@ 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
4
  import { ROLE_NAMES, assignableToEnvironment } from "@coffre/core/access";
5
- import { bigint, boolean, check, customType, foreignKey, index, integer, pgTable, text, timestamp, unique, uniqueIndex, uuid } from "drizzle-orm/pg-core";
5
+ import { bigint, boolean, check, customType, foreignKey, index, integer, pgTable, primaryKey, text, timestamp, unique, uniqueIndex, uuid } from "drizzle-orm/pg-core";
6
6
  //#region src/schema.ts
7
7
  var schema_exports = /* @__PURE__ */ __exportAll({
8
8
  auditChainHead: () => auditChainHead,
@@ -10,10 +10,15 @@ var schema_exports = /* @__PURE__ */ __exportAll({
10
10
  consumedTokens: () => consumedTokens,
11
11
  credentials: () => credentials,
12
12
  deviceAuthorizations: () => deviceAuthorizations,
13
+ dismissedKeys: () => dismissedKeys,
13
14
  environments: () => environments,
14
15
  environmentsRelations: () => environmentsRelations,
15
16
  identities: () => identities,
17
+ mcpApprovals: () => mcpApprovals,
18
+ mcpConnections: () => mcpConnections,
19
+ oauthClients: () => oauthClients,
16
20
  projects: () => projects,
21
+ secretReferences: () => secretReferences,
17
22
  secretVersions: () => secretVersions,
18
23
  secrets: () => secrets,
19
24
  secretsRelations: () => secretsRelations,
@@ -23,13 +28,31 @@ var schema_exports = /* @__PURE__ */ __exportAll({
23
28
  });
24
29
  const bytea = customType({ dataType: () => "bytea" });
25
30
  const createdAt = () => timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
31
+ /**
32
+ * A project's or environment's slug: `market`, or once deleted, its
33
+ * tombstone's, `market~deleted-2026-10-05`. No live slug holds a `~`, so a
34
+ * tombstone frees its slug and is never mistaken for what takes it next.
35
+ */
36
+ const PLACE_SLUG = sql.raw(`'^[a-z0-9][a-z0-9-]{0,62}(~[a-z0-9-]{1,40})?$'`);
37
+ /**
38
+ * The folder a project or a secret is listed in: a folder lists rows
39
+ * together and does nothing else; it grants, hides and renames nothing. A
40
+ * name, not a row of its own: 1 to 64 characters, no `/`, no control
41
+ * character, no space at either end. Null is no folder.
42
+ */
43
+ const folderCheck = (name, column) => check(name, sql`${column} IS NULL OR (char_length(${column}) BETWEEN 1 AND 64 AND ${column} !~ '[/[:cntrl:]]' AND ${column} = btrim(${column}))`);
26
44
  const projects = pgTable("projects", {
27
45
  id: uuid().primaryKey().defaultRandom(),
28
46
  slug: text().notNull(),
29
47
  name: text().notNull(),
30
48
  createdAt: createdAt(),
31
- archivedAt: timestamp("archived_at", { withTimezone: true })
32
- }, (table) => [unique("projects_slug_key").on(table.slug), check("projects_slug_check", sql`${table.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`)]);
49
+ archivedAt: timestamp("archived_at", { withTimezone: true }),
50
+ folder: text()
51
+ }, (table) => [
52
+ unique("projects_slug_key").on(table.slug),
53
+ check("projects_slug_check", sql`${table.slug} ~ ${PLACE_SLUG}`),
54
+ folderCheck("projects_folder_check", table.folder)
55
+ ]);
33
56
  const environments = pgTable("environments", {
34
57
  id: uuid().primaryKey().defaultRandom(),
35
58
  projectId: uuid("project_id").notNull(),
@@ -40,7 +63,7 @@ const environments = pgTable("environments", {
40
63
  }, (table) => [
41
64
  unique("environments_project_id_slug_key").on(table.projectId, table.slug),
42
65
  unique("environments_project_scoped").on(table.id, table.projectId),
43
- check("environments_slug_check", sql`${table.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
66
+ check("environments_slug_check", sql`${table.slug} ~ ${PLACE_SLUG}`),
44
67
  foreignKey({
45
68
  name: "environments_project_id_fkey",
46
69
  columns: [table.projectId],
@@ -56,10 +79,12 @@ const secrets = pgTable("secrets", {
56
79
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
57
80
  currentVersionId: uuid("current_version_id").references(() => secretVersions.id, { onDelete: "no action" }),
58
81
  currentVersion: integer("current_version").notNull().default(0),
59
- archivedAt: timestamp("archived_at", { withTimezone: true })
82
+ archivedAt: timestamp("archived_at", { withTimezone: true }),
83
+ folder: text()
60
84
  }, (table) => [
61
85
  unique("secrets_project_id_environment_id_key_key").on(table.projectId, table.environmentId, table.key),
62
86
  check("secrets_key_check", sql`${table.key} ~ '^[A-Za-z_][A-Za-z0-9_]{0,127}$'`),
87
+ folderCheck("secrets_folder_check", table.folder),
63
88
  foreignKey({
64
89
  name: "secrets_project_id_fkey",
65
90
  columns: [table.projectId],
@@ -103,6 +128,32 @@ const secretVersions = pgTable("secret_versions", {
103
128
  }).onDelete("restrict"),
104
129
  index("secret_versions_secret_idx").on(table.secretId, table.version.desc())
105
130
  ]);
131
+ /**
132
+ * Keys an environment is missing, which its team decided it does not need:
133
+ * the project's other environments have them, and its missing-keys list
134
+ * leaves them out until someone restores them (docs/design/environments.md).
135
+ * One row per environment and key, updated in place: restoring sets
136
+ * `restored_at`, dismissing again clears it.
137
+ */
138
+ const dismissedKeys = pgTable("dismissed_keys", {
139
+ environmentId: uuid("environment_id").notNull(),
140
+ key: text().notNull(),
141
+ dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(),
142
+ dismissedBy: text("dismissed_by").notNull(),
143
+ restoredAt: timestamp("restored_at", { withTimezone: true }),
144
+ restoredBy: text("restored_by")
145
+ }, (table) => [
146
+ primaryKey({
147
+ name: "dismissed_keys_pkey",
148
+ columns: [table.environmentId, table.key]
149
+ }),
150
+ check("dismissed_keys_key_check", sql`${table.key} ~ '^[A-Za-z_][A-Za-z0-9_]{0,127}$'`),
151
+ foreignKey({
152
+ name: "dismissed_keys_environment_id_fkey",
153
+ columns: [table.environmentId],
154
+ foreignColumns: [environments.id]
155
+ }).onDelete("restrict")
156
+ ]);
106
157
  /** The role catalogue, as SQL: every role, and those an environment may be granted. */
107
158
  const ROLES = sql.raw(ROLE_NAMES.map((role) => `'${role}'`).join(", "));
108
159
  const ENVIRONMENT_ROLES = sql.raw(ROLE_NAMES.filter(assignableToEnvironment).map((role) => `'${role}'`).join(", "));
@@ -110,7 +161,7 @@ const ENVIRONMENT_ROLES = sql.raw(ROLE_NAMES.filter(assignableToEnvironment).map
110
161
  * The vault's member directory: everyone it has admitted, written by the
111
162
  * vault alone, read by both. A principal with no row is no member.
112
163
  * Principals are the strings coffre uses at its edges: `user:<email>`,
113
- * `token:<id>`, `sync:<id>`. Times are milliseconds since the epoch, each
164
+ * `token:<id>`. Times are milliseconds since the epoch, each
114
165
  * the time of the vault's log entry that set it, so the log replays to
115
166
  * these rows exactly.
116
167
  */
@@ -127,31 +178,37 @@ const vaultMembers = pgTable("vault_members", {
127
178
  mac: bytea().notNull()
128
179
  }, (table) => [
129
180
  check("vault_members_mac_check", sql`octet_length(${table.mac}) = 32`),
130
- check("vault_members_principal_check", sql`${table.principal} ~ '^(user|token|sync):[^[:space:]:][^[:space:]]*$'`),
181
+ check("vault_members_principal_check", sql`${table.principal} ~ '^(user|token):[^[:space:]:][^[:space:]]*$'`),
131
182
  check("vault_members_user_lowercase", sql`${table.principal} NOT LIKE 'user:%' OR ${table.principal} = lower(${table.principal})`),
132
183
  check("vault_members_status_check", sql`${table.status} IN ('active', 'removed')`),
133
184
  check("vault_members_owner_check", sql`NOT ${table.owner} OR (${table.status} = 'active' AND ${table.principal} LIKE 'user:%')`),
134
185
  check("vault_members_generation_check", sql`${table.generation} >= 0`)
135
186
  ]);
136
187
  /**
137
- * One role per member per place: a project, or one of its environments,
138
- * never both. A revoked grant is deleted; an expired one stays until its
139
- * place is granted again, so the members page can say it lapsed.
188
+ * One role per member per place: a project, one of its environments, every
189
+ * project (`*`, neither id), or the environment of one slug in every project
190
+ * (neither id, and `environment_slug`). A revoked grant is deleted; an
191
+ * expired one stays until its place is granted again, so the members page
192
+ * can say it lapsed.
140
193
  */
141
194
  const vaultGrants = pgTable("vault_grants", {
142
195
  principal: text().notNull(),
143
196
  projectId: uuid("project_id"),
144
197
  environmentId: uuid("environment_id"),
198
+ environmentSlug: text("environment_slug"),
145
199
  role: text().notNull(),
146
200
  expiresAt: bigint("expires_at", { mode: "number" }),
147
201
  grantedAt: bigint("granted_at", { mode: "number" }).notNull(),
148
202
  grantedBy: text("granted_by").notNull()
149
203
  }, (table) => [
150
- check("vault_grants_one_place", sql`(${table.projectId} IS NULL) <> (${table.environmentId} IS NULL)`),
204
+ 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)`),
205
+ check("vault_grants_environment_slug_check", sql`${table.environmentSlug} IS NULL OR ${table.environmentSlug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
151
206
  check("vault_grants_role_check", sql`${table.role} IN (${ROLES})`),
152
- check("vault_grants_environment_role_check", sql`${table.environmentId} IS NULL OR ${table.role} IN (${ENVIRONMENT_ROLES})`),
207
+ check("vault_grants_environment_role_check", sql`(${table.environmentId} IS NULL AND ${table.environmentSlug} IS NULL) OR ${table.role} IN (${ENVIRONMENT_ROLES})`),
153
208
  unique("vault_grants_on_project").on(table.principal, table.projectId),
154
209
  unique("vault_grants_on_environment").on(table.principal, table.environmentId),
210
+ unique("vault_grants_on_environment_slug").on(table.principal, table.environmentSlug),
211
+ uniqueIndex("vault_grants_on_every_project").on(table.principal).where(sql`${table.projectId} IS NULL AND ${table.environmentId} IS NULL AND ${table.environmentSlug} IS NULL`),
155
212
  foreignKey({
156
213
  name: "vault_grants_principal_fkey",
157
214
  columns: [table.principal],
@@ -192,7 +249,7 @@ const auditLog = pgTable("audit_log", {
192
249
  hash: bytea().notNull()
193
250
  }, (table) => [
194
251
  check("audit_log_author_check", sql`${table.author} IN ('app', 'vault')`),
195
- check("audit_log_actor_check", sql`${table.actor} ~ '^(user|token|sync|system):.+$'`),
252
+ check("audit_log_actor_check", sql`${table.actor} ~ '^(user|token|system):.+$'`),
196
253
  check("audit_log_decision_check", sql`${table.decision} IN ('allow', 'deny')`),
197
254
  check("audit_log_metadata_check", sql`${table.metadata}::jsonb IS NOT NULL`),
198
255
  check("audit_log_prev_hash_check", sql`octet_length(${table.prevHash}) = 32`),
@@ -232,7 +289,53 @@ const auditLog = pgTable("audit_log", {
232
289
  index("audit_log_releases_idx").on(table.author, table.actor, table.action, table.decision, table.occurredAt),
233
290
  index("audit_log_subject_idx").on(table.author, table.subjectPrincipal, table.seq),
234
291
  index("audit_log_unbind_idx").on(sql`((${table.metadata})::jsonb ->> 'bindingId')`).where(sql`${table.author} = 'app' AND ${table.action} = 'token.unbind' AND ${table.decision} = 'allow'`),
235
- index("audit_log_exchange_idx").on(sql`((${table.metadata})::jsonb ->> 'credentialId')`).where(sql`${table.author} = 'app' AND ${table.action} = 'token.exchange' AND ${table.decision} = 'allow'`)
292
+ index("audit_log_exchange_idx").on(sql`((${table.metadata})::jsonb ->> 'credentialId')`).where(sql`${table.author} = 'app' AND ${table.action} = 'token.exchange' AND ${table.decision} = 'allow'`),
293
+ index("audit_log_reference_end_idx").on(table.relatedSeq).where(sql`${table.author} = 'vault' AND ${table.action} = 'reference.end' AND ${table.decision} = 'allow'`),
294
+ index("audit_log_also_project_idx").on(sql`(((${table.metadata})::jsonb -> 'also') ->> 'projectId')`, table.seq).where(sql`((${table.metadata})::jsonb -> 'also') IS NOT NULL`),
295
+ index("audit_log_also_environment_idx").on(sql`(((${table.metadata})::jsonb -> 'also') ->> 'environmentId')`, table.seq).where(sql`((${table.metadata})::jsonb -> 'also') IS NOT NULL`),
296
+ index("audit_log_also_secret_idx").on(sql`(((${table.metadata})::jsonb -> 'also') ->> 'secretId')`, table.seq).where(sql`((${table.metadata})::jsonb -> 'also') IS NOT NULL`)
297
+ ]);
298
+ /**
299
+ * A reference: a secret whose value is another's, read live through it by
300
+ * whoever reads the holder's environment (docs/design/environments.md).
301
+ * The row only points: the reference is the vault's `reference.create`
302
+ * entry at `created_seq`, which the vault checks at every read, and a
303
+ * `reference.end` naming that entry ends it, whatever this table says. So
304
+ * rows are only ever added.
305
+ */
306
+ const secretReferences = pgTable("secret_references", {
307
+ id: uuid().primaryKey(),
308
+ projectId: uuid("project_id").notNull(),
309
+ environmentId: uuid("environment_id").notNull(),
310
+ secretId: uuid("secret_id").notNull(),
311
+ sourceProjectId: uuid("source_project_id").notNull(),
312
+ sourceEnvironmentId: uuid("source_environment_id").notNull(),
313
+ sourceSecretId: uuid("source_secret_id").notNull(),
314
+ createdSeq: bigint("created_seq", { mode: "bigint" }).notNull(),
315
+ createdAt: createdAt(),
316
+ createdBy: text("created_by").notNull()
317
+ }, (table) => [
318
+ unique("secret_references_created_seq_key").on(table.createdSeq),
319
+ foreignKey({
320
+ name: "secret_references_secret_id_fkey",
321
+ columns: [table.secretId],
322
+ foreignColumns: [secrets.id]
323
+ }).onDelete("restrict"),
324
+ foreignKey({
325
+ name: "secret_references_source_secret_id_fkey",
326
+ columns: [table.sourceSecretId],
327
+ foreignColumns: [secrets.id]
328
+ }).onDelete("restrict"),
329
+ foreignKey({
330
+ name: "secret_references_created_seq_fkey",
331
+ columns: [table.createdSeq],
332
+ foreignColumns: [auditLog.seq]
333
+ }).onDelete("restrict"),
334
+ index("secret_references_holder_idx").on(table.secretId, table.createdSeq),
335
+ index("secret_references_source_idx").on(table.sourceSecretId),
336
+ index("secret_references_project_idx").on(table.projectId),
337
+ index("secret_references_source_project_idx").on(table.sourceProjectId),
338
+ index("secret_references_created_by_idx").on(table.createdBy)
236
339
  ]);
237
340
  const auditChainHead = pgTable("audit_chain_head", {
238
341
  onlyRow: boolean("only_row").primaryKey().default(true),
@@ -324,6 +427,96 @@ const consumedTokens = pgTable("consumed_tokens", {
324
427
  consumedAt: timestamp("consumed_at", { withTimezone: true }).notNull().defaultNow()
325
428
  }, (table) => [check("consumed_tokens_hash_check", sql`octet_length(${table.hash}) = 32`)]);
326
429
  /**
430
+ * MCP clients that registered themselves (RFC 7591), the fallback to Client
431
+ * ID Metadata Documents, which need no row. The ID is the client's
432
+ * `client_id`. Its redirects are under its MAC: a database writer cannot add
433
+ * one of their own to a client people have approved.
434
+ */
435
+ const oauthClients = pgTable("oauth_clients", {
436
+ id: uuid().primaryKey(),
437
+ authMac: bytea("auth_mac").notNull(),
438
+ name: text().notNull(),
439
+ redirectUris: text("redirect_uris").notNull(),
440
+ createdAt: createdAt(),
441
+ createdIp: text("created_ip"),
442
+ revokedAt: timestamp("revoked_at", { withTimezone: true })
443
+ }, (table) => [check("oauth_clients_auth_mac_check", sql`octet_length(${table.authMac}) = 32`), check("oauth_clients_redirect_uris_check", sql`${table.redirectUris}::jsonb IS NOT NULL`)]);
444
+ /**
445
+ * One MCP client a person connected: one approved authorization, its
446
+ * scopes, its code until the client redeems it, and the refresh token it
447
+ * holds since. Access tokens have no row: each names its connection under a
448
+ * MAC, and every request checks this row. Revoking it ends them all.
449
+ */
450
+ const mcpConnections = pgTable("mcp_connections", {
451
+ id: uuid().primaryKey(),
452
+ authMac: bytea("auth_mac").notNull(),
453
+ principal: text().notNull(),
454
+ generation: integer().notNull(),
455
+ clientId: text("client_id").notNull(),
456
+ clientName: text("client_name").notNull(),
457
+ clientHost: text("client_host"),
458
+ registration: text().notNull(),
459
+ scopes: text().notNull(),
460
+ redirectUri: text("redirect_uri").notNull(),
461
+ codeHash: bytea("code_hash"),
462
+ codeChallenge: text("code_challenge"),
463
+ codeExpiresAt: timestamp("code_expires_at", { withTimezone: true }),
464
+ refreshHash: bytea("refresh_hash"),
465
+ refreshPreviousHash: bytea("refresh_previous_hash"),
466
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
467
+ createdAt: createdAt(),
468
+ lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
469
+ lastUsedIp: text("last_used_ip"),
470
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
471
+ revokedBy: text("revoked_by")
472
+ }, (table) => [
473
+ check("mcp_connections_principal_check", sql`${table.principal} LIKE 'user:%'`),
474
+ check("mcp_connections_registration_check", sql`${table.registration} IN ('cimd', 'dcr')`),
475
+ check("mcp_connections_auth_mac_check", sql`octet_length(${table.authMac}) = 32`),
476
+ check("mcp_connections_code_hash_check", sql`${table.codeHash} IS NULL OR octet_length(${table.codeHash}) = 32`),
477
+ check("mcp_connections_refresh_hash_check", sql`${table.refreshHash} IS NULL OR octet_length(${table.refreshHash}) = 32`),
478
+ foreignKey({
479
+ name: "mcp_connections_principal_fkey",
480
+ columns: [table.principal],
481
+ foreignColumns: [vaultMembers.principal]
482
+ }).onDelete("restrict"),
483
+ unique("mcp_connections_code_hash_key").on(table.codeHash),
484
+ unique("mcp_connections_refresh_hash_key").on(table.refreshHash),
485
+ index("mcp_connections_refresh_previous_idx").on(table.refreshPreviousHash),
486
+ index("mcp_connections_live_idx").on(table.principal, table.expiresAt).where(sql`${table.revokedAt} IS NULL`)
487
+ ]);
488
+ /**
489
+ * A change an MCP client asked for, waiting for its person on coffre's own
490
+ * page (docs/design/mcp.md, section 7). It holds the tool and its arguments,
491
+ * never a value: the page shows what they would do and, on Approve, does it,
492
+ * once, as the person, through the connection. Its digest is of the tool
493
+ * and arguments, which the page sends back with the decision, so what runs
494
+ * is what the person read. Not MACed: a row changes nothing by itself.
495
+ */
496
+ const mcpApprovals = pgTable("mcp_approvals", {
497
+ id: uuid().primaryKey(),
498
+ connectionId: uuid("connection_id").notNull(),
499
+ tool: text().notNull(),
500
+ arguments: text().notNull(),
501
+ digest: bytea().notNull(),
502
+ status: text().notNull().default("pending"),
503
+ outcome: text(),
504
+ createdAt: createdAt(),
505
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
506
+ decidedAt: timestamp("decided_at", { withTimezone: true }),
507
+ reportedAt: timestamp("reported_at", { withTimezone: true })
508
+ }, (table) => [
509
+ check("mcp_approvals_status_check", sql`${table.status} IN ('pending', 'approved', 'denied', 'cancelled', 'failed')`),
510
+ check("mcp_approvals_digest_check", sql`octet_length(${table.digest}) = 32`),
511
+ check("mcp_approvals_arguments_check", sql`${table.arguments}::jsonb IS NOT NULL`),
512
+ foreignKey({
513
+ name: "mcp_approvals_connection_fkey",
514
+ columns: [table.connectionId],
515
+ foreignColumns: [mcpConnections.id]
516
+ }).onDelete("restrict"),
517
+ index("mcp_approvals_open_idx").on(table.connectionId, table.digest).where(sql`${table.reportedAt} IS NULL`)
518
+ ]);
519
+ /**
327
520
  * Bearer credentials coffre issues itself: browser sessions, CLI sessions and
328
521
  * service tokens.
329
522
  *
@@ -421,4 +614,4 @@ const { environmentsRelations, secretsRelations } = relationsOf({
421
614
  secretVersions
422
615
  });
423
616
  //#endregion
424
- export { auditChainHead, auditLog, consumedTokens, credentials, deviceAuthorizations, environments, environmentsRelations, identities, projects, secretVersions, secrets, secretsRelations, serviceBindings, schema_exports as t, vaultGrants, vaultMembers };
617
+ export { auditChainHead, auditLog, consumedTokens, credentials, deviceAuthorizations, dismissedKeys, environments, environmentsRelations, identities, mcpApprovals, mcpConnections, oauthClients, projects, secretReferences, secretVersions, secrets, secretsRelations, serviceBindings, schema_exports as t, vaultGrants, vaultMembers };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/db",
3
- "version": "0.3.0",
3
+ "version": "0.4.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": {
@@ -67,6 +67,11 @@
67
67
  "types": "./dist/log.d.ts",
68
68
  "default": "./dist/log.js"
69
69
  },
70
+ "./grants": {
71
+ "coffre:source": "./src/grants.ts",
72
+ "types": "./dist/grants.d.ts",
73
+ "default": "./dist/grants.js"
74
+ },
70
75
  "./package.json": "./package.json"
71
76
  },
72
77
  "dependencies": {
@@ -74,7 +79,7 @@
74
79
  "drizzle-orm": "0.45.2",
75
80
  "pg": "8.16.3",
76
81
  "pg-connection-string": "2.14.0",
77
- "@coffre/core": "0.3.0"
82
+ "@coffre/core": "0.4.1"
78
83
  },
79
84
  "devDependencies": {
80
85
  "@types/node": "26.1.1",
@@ -26,13 +26,35 @@ CREATE TRIGGER audit_log_no_truncate BEFORE TRUNCATE ON audit_log
26
26
  FOR EACH STATEMENT EXECUTE FUNCTION audit_log_append_only();
27
27
  --> statement-breakpoint
28
28
 
29
+ -- A secret version is never rewritten in place, only erased: a deleted
30
+ -- project or environment stays as a tombstone the log's entries name, and
31
+ -- its versions with it, their ciphertext and wrapped data key emptied. That
32
+ -- is the one change a version takes, for every login.
33
+ CREATE FUNCTION secret_versions_erase_only() RETURNS trigger LANGUAGE plpgsql AS $$
34
+ BEGIN
35
+ IF octet_length(NEW.ciphertext) <> 0 OR octet_length(NEW.wrapped_dek) <> 0
36
+ OR (NEW.id, NEW.secret_id, NEW.version, NEW.envelope_version, NEW.iv, NEW.auth_tag,
37
+ NEW.kek_provider, NEW.kek_id, NEW.kek_version, NEW.created_at, NEW.created_by)
38
+ IS DISTINCT FROM
39
+ (OLD.id, OLD.secret_id, OLD.version, OLD.envelope_version, OLD.iv, OLD.auth_tag,
40
+ OLD.kek_provider, OLD.kek_id, OLD.kek_version, OLD.created_at, OLD.created_by) THEN
41
+ RAISE EXCEPTION 'a secret version is only ever erased' USING ERRCODE = 'insufficient_privilege';
42
+ END IF;
43
+ RETURN NEW;
44
+ END
45
+ $$;
46
+ --> statement-breakpoint
47
+ CREATE TRIGGER secret_versions_erase_only BEFORE UPDATE ON secret_versions
48
+ FOR EACH ROW EXECUTE FUNCTION secret_versions_erase_only();
49
+ --> statement-breakpoint
50
+
29
51
  -- Two logins, provisioned outside coffre (their passwords are never ours),
30
52
  -- each with only the rights of its group:
31
53
  --
32
54
  -- coffre_runtime the server, through coffre_app: read and insert,
33
55
  -- UPDATE on named columns only, and never DELETE. A
34
- -- revoked session, a used device code and a removed
35
- -- sync key are rows that say so, not gaps.
56
+ -- revoked session, a used device code and a spent
57
+ -- token are rows that say so, not gaps.
36
58
  -- coffre_vault_runtime the vault, through coffre_vault: its members and
37
59
  -- grants, and reading what it decides on.
38
60
  --
@@ -114,6 +136,10 @@ GRANT USAGE ON SCHEMA drizzle TO coffre_app, coffre_vault;
114
136
  GRANT SELECT ON drizzle.__drizzle_migrations TO coffre_app, coffre_vault;
115
137
  --> statement-breakpoint
116
138
 
139
+ -- The app reads and adds; it never deletes. A binding, a registered client
140
+ -- and a connection change only in what their MAC allows to change in place;
141
+ -- a spent token and a reference never change at all, and the vault's log
142
+ -- says which references have ended.
117
143
  GRANT SELECT, INSERT ON
118
144
  projects,
119
145
  environments,
@@ -123,8 +149,12 @@ GRANT SELECT, INSERT ON
123
149
  identities,
124
150
  credentials,
125
151
  device_authorizations,
126
- syncs,
127
- sync_keys
152
+ service_bindings,
153
+ consumed_tokens,
154
+ secret_references,
155
+ dismissed_keys,
156
+ oauth_clients,
157
+ mcp_connections
128
158
  TO coffre_app;
129
159
  --> statement-breakpoint
130
160
 
@@ -135,11 +165,11 @@ GRANT SELECT ON
135
165
  TO coffre_app;
136
166
  --> statement-breakpoint
137
167
 
138
- GRANT UPDATE (slug, name, archived_at) ON projects TO coffre_app;
168
+ GRANT UPDATE (slug, name, archived_at, folder) ON projects TO coffre_app;
139
169
  --> statement-breakpoint
140
170
  GRANT UPDATE (slug, name, archived_at) ON environments TO coffre_app;
141
171
  --> statement-breakpoint
142
- GRANT UPDATE (key, current_version_id, current_version, updated_at, archived_at) ON secrets TO coffre_app;
172
+ GRANT UPDATE (key, current_version_id, current_version, updated_at, archived_at, folder) ON secrets TO coffre_app;
143
173
  --> statement-breakpoint
144
174
  GRANT UPDATE (next_seq, head_hash) ON audit_chain_head TO coffre_app;
145
175
  --> statement-breakpoint
@@ -150,11 +180,14 @@ GRANT UPDATE (last_used_at, last_used_ip, revoked_at, revoked_by, auth_mac) ON c
150
180
  GRANT UPDATE (decided_at, decision, generation, principal, consumed_at, auth_mac)
151
181
  ON device_authorizations TO coffre_app;
152
182
  --> statement-breakpoint
153
- GRANT UPDATE (config, credential_secret_id, paused_at, archived_at, lease_until,
154
- last_run_at, last_status, last_error)
155
- ON syncs TO coffre_app;
183
+ GRANT UPDATE (label, last_used_at, revoked_at, revoked_by, auth_mac) ON service_bindings TO coffre_app;
184
+ --> statement-breakpoint
185
+ GRANT UPDATE (dismissed_at, dismissed_by, restored_at, restored_by) ON dismissed_keys TO coffre_app;
156
186
  --> statement-breakpoint
157
- GRANT UPDATE (secret_version_id, pushed_at, removed_at) ON sync_keys TO coffre_app;
187
+ GRANT UPDATE (revoked_at, auth_mac) ON oauth_clients TO coffre_app;
188
+ --> statement-breakpoint
189
+ GRANT UPDATE (code_challenge, code_expires_at, refresh_hash, refresh_previous_hash, last_used_at, last_used_ip, revoked_at, revoked_by, auth_mac)
190
+ ON mcp_connections TO coffre_app;
158
191
  --> statement-breakpoint
159
192
 
160
193
  -- The vault reads what it decides on and the log it chains to, and writes
@@ -183,6 +216,9 @@ REVOKE TRUNCATE ON ALL TABLES IN SCHEMA public FROM coffre_vault;
183
216
  --> statement-breakpoint
184
217
  REVOKE UPDATE ON audit_log, secret_versions FROM coffre_app, coffre_vault;
185
218
  --> statement-breakpoint
219
+ -- What erasing a version empties, and nothing else: the trigger above holds every login to it.
220
+ GRANT UPDATE (ciphertext, wrapped_dek) ON secret_versions TO coffre_app;
221
+ --> statement-breakpoint
186
222
  REVOKE CREATE ON SCHEMA public FROM PUBLIC, coffre_app, coffre_runtime, coffre_vault, coffre_vault_runtime;
187
223
  --> statement-breakpoint
188
224
 
@@ -25,3 +25,18 @@ CREATE TRIGGER `audit_log_no_delete` BEFORE DELETE ON `audit_log`
25
25
  BEGIN
26
26
  SELECT RAISE(ABORT, 'audit_log is append-only');
27
27
  END;
28
+ --> statement-breakpoint
29
+
30
+ -- A secret version is never rewritten in place, only erased: a deleted
31
+ -- project or environment stays as a tombstone the log's entries name, and
32
+ -- its versions with it, their ciphertext and wrapped data key emptied. That
33
+ -- is the one change a version takes.
34
+ CREATE TRIGGER `secret_versions_erase_only` BEFORE UPDATE ON `secret_versions`
35
+ WHEN length(NEW.`ciphertext`) <> 0 OR length(NEW.`wrapped_dek`) <> 0
36
+ OR NEW.`id` IS NOT OLD.`id` OR NEW.`secret_id` IS NOT OLD.`secret_id` OR NEW.`version` IS NOT OLD.`version`
37
+ OR NEW.`envelope_version` IS NOT OLD.`envelope_version` OR NEW.`iv` IS NOT OLD.`iv` OR NEW.`auth_tag` IS NOT OLD.`auth_tag`
38
+ OR NEW.`kek_provider` IS NOT OLD.`kek_provider` OR NEW.`kek_id` IS NOT OLD.`kek_id` OR NEW.`kek_version` IS NOT OLD.`kek_version`
39
+ OR NEW.`created_at` IS NOT OLD.`created_at` OR NEW.`created_by` IS NOT OLD.`created_by`
40
+ BEGIN
41
+ SELECT RAISE(ABORT, 'a secret version is only ever erased');
42
+ END;
package/src/baseline.ts CHANGED
@@ -20,8 +20,11 @@ import * as sqlite from './schema.sqlite.ts';
20
20
  *
21
21
  * A baseline is baseline/<engine>.sql with its `-- @schema` line replaced by
22
22
  * the tables drizzle-kit generates from that engine's schema. The template
23
- * holds what a schema cannot say: the audit chain's first rows, and the
24
- * Postgres runtime role and its grants.
23
+ * holds what a schema cannot say: the audit chain's first rows, the audit
24
+ * log's append-only and the secret versions' erase-only triggers, and the
25
+ * Postgres runtime roles and their grants. Since 0.4.0, every database starts
26
+ * from this one baseline; an expression index's SQL is written by hand in it,
27
+ * as drizzle-kit splits one at its comma on SQLite.
25
28
  *
26
29
  * `pnpm db:generate` runs this file and appends pending schema changes.
27
30
  */
@@ -79,7 +82,8 @@ function generate(engine: Engine): void {
79
82
  execFileSync(
80
83
  `${packageRoot}node_modules/.bin/drizzle-kit`,
81
84
  ['generate', '--config', `drizzle.${engine}.config.ts`, '--name', process.argv[2] ?? 'schema'],
82
- { cwd: here, stdio: 'inherit' },
85
+ // drizzle-kit loads the schema, which imports the workspace's other packages by their sources.
86
+ { cwd: here, stdio: 'inherit', env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --conditions=coffre:source`.trim() } },
83
87
  );
84
88
  }
85
89
 
package/src/dialect.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { entityKind, getTableColumns, sql, type SQL } from 'drizzle-orm';
1
+ import { TOMBSTONE } from '@coffre/core/schemas';
2
+ import { entityKind, getTableColumns, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
2
3
  import type { PgTable } from 'drizzle-orm/pg-core';
3
4
 
4
5
  import type { Queryable } from './database.ts';
@@ -74,6 +75,11 @@ export async function upsert<T extends Table>(
74
75
  });
75
76
  }
76
77
 
78
+ /** A tombstone's slug, `@coffre/core/schemas`'s `isTombstone` in SQL. No LIKE wildcard is a `~`. */
79
+ export function tombstone(slug: SQLWrapper): SQL {
80
+ return sql`${slug} LIKE ${`%${TOMBSTONE}%`}`;
81
+ }
82
+
77
83
  /** A condition read as a boolean: Postgres returns true or false, SQLite 1 or 0. */
78
84
  export function truth(condition: SQL): SQL<boolean> {
79
85
  return condition.mapWith((value) => Number(value) === 1);
@@ -97,6 +103,24 @@ export function isUniqueViolation(error: unknown): boolean {
97
103
  return false;
98
104
  }
99
105
 
106
+ /** The network's codes for a server that is not there, and Postgres's for one that will not take the connection. */
107
+ const UNREACHABLE = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'EPIPE', '57P01', '57P02', '57P03']);
108
+
109
+ /**
110
+ * Whether a query failed because the database could not be reached or
111
+ * dropped the connection: an outage, not an answer. Postgres's connection
112
+ * exceptions are class 08; pg and Workers' sockets say so in words.
113
+ */
114
+ export function isUnreachable(error: unknown): boolean {
115
+ for (let cause = error; typeof cause === 'object' && cause !== null; ) {
116
+ const { code, message } = cause as { code?: unknown; message?: unknown };
117
+ if (typeof code === 'string' && (UNREACHABLE.has(code) || /^08[0-9A-Z]{3}$/.test(code))) return true;
118
+ if (typeof message === 'string' && /connection terminated|network connection lost|connect(ion)? timeout/i.test(message)) return true;
119
+ cause = (cause as { cause?: unknown }).cause;
120
+ }
121
+ return false;
122
+ }
123
+
100
124
  /**
101
125
  * A consistent read of several statements that cannot write, such as
102
126
  * verifying the chain. SQLite ignores it: its transactions already see one
package/src/grants.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { and, eq, gt, isNull, or } from 'drizzle-orm';
2
+
3
+ import { tablesOf, type Queryable } from './database.ts';
4
+
5
+ /**
6
+ * One grant, as `vault_grants` holds it: on a project (`environmentId`
7
+ * null), one of its environments (`projectId` is the environment's
8
+ * project), every project (both ids null), or one environment slug in every
9
+ * project (`environmentSlug`). Only the vault writes grants; the app reads
10
+ * them for its lists.
11
+ */
12
+ export type GrantRow = {
13
+ principal: string;
14
+ projectId: string | null;
15
+ environmentId: string | null;
16
+ environmentSlug: string | null;
17
+ role: string;
18
+ /** Milliseconds since the epoch, or null for no end. */
19
+ expiresAt: number | null;
20
+ grantedAt: number;
21
+ grantedBy: string;
22
+ };
23
+
24
+ /**
25
+ * Grants, lapsed ones too unless `liveAt` says when to judge them: one
26
+ * member's, or everyone's; `everyProject`, only those on every project.
27
+ */
28
+ export async function readGrants(
29
+ db: Queryable,
30
+ filter: { principal?: string; liveAt?: number; everyProject?: boolean } = {},
31
+ ): Promise<GrantRow[]> {
32
+ const { vaultGrants, environments } = tablesOf(db);
33
+ const rows = await db
34
+ .select({ grant: vaultGrants, environmentProjectId: environments.projectId })
35
+ .from(vaultGrants)
36
+ .leftJoin(environments, eq(environments.id, vaultGrants.environmentId))
37
+ .where(
38
+ and(
39
+ filter.principal === undefined ? undefined : eq(vaultGrants.principal, filter.principal),
40
+ filter.liveAt === undefined ? undefined : or(isNull(vaultGrants.expiresAt), gt(vaultGrants.expiresAt, filter.liveAt)),
41
+ filter.everyProject === true ? and(isNull(vaultGrants.projectId), isNull(vaultGrants.environmentId)) : undefined,
42
+ ),
43
+ );
44
+ return rows.map(({ grant, environmentProjectId }) => ({ ...grant, projectId: grant.projectId ?? environmentProjectId }));
45
+ }
46
+
47
+ /** Add a grant; an environment's row names only the environment. */
48
+ export async function insertGrant(db: Queryable, grant: GrantRow): Promise<void> {
49
+ const { vaultGrants } = tablesOf(db);
50
+ await db.insert(vaultGrants).values({ ...grant, projectId: grant.environmentId === null ? grant.projectId : null });
51
+ }
package/src/migrate.ts CHANGED
@@ -122,6 +122,9 @@ export class DatabaseAhead extends Error {
122
122
  }
123
123
  }
124
124
 
125
+ /** The release whose baseline every database since was made from. */
126
+ const CLEAN_BREAK = '0.4.0';
127
+
125
128
  function verifyHistory(
126
129
  expected: ExpectedMigration[],
127
130
  applied: AppliedMigration[] | null,
@@ -131,6 +134,8 @@ function verifyHistory(
131
134
  rows.slice(0, expected.length).forEach((row, index) => {
132
135
  const local = expected[index]!;
133
136
  if (row.created_at !== local.createdAt || row.hash !== local.hash) {
137
+ // 0.4.0 began again from one baseline: a database from before it is not upgraded, but made afresh.
138
+ if (index === 0) throw new Error(`the database was made by a coffre before ${CLEAN_BREAK}, which is a clean break: deploy afresh, on a new database (docs/deploy.md)`);
134
139
  throw new Error(
135
140
  `migration history diverged at ${local.tag}; applied migrations must never be edited`,
136
141
  );