@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/src/schema.ts CHANGED
@@ -26,6 +26,22 @@ const bytea = customType<{ data: Buffer }>({
26
26
 
27
27
  const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
28
28
 
29
+ /**
30
+ * A project's or environment's slug: `market`, or once deleted, its
31
+ * tombstone's, `market~deleted-2026-10-05`. No live slug holds a `~`, so a
32
+ * tombstone frees its slug and is never mistaken for what takes it next.
33
+ */
34
+ const PLACE_SLUG = sql.raw(`'^[a-z0-9][a-z0-9-]{0,62}(~[a-z0-9-]{1,40})?$'`);
35
+
36
+ /**
37
+ * The folder a project or a secret is listed in: a folder lists rows
38
+ * together and does nothing else; it grants, hides and renames nothing. A
39
+ * name, not a row of its own: 1 to 64 characters, no `/`, no control
40
+ * character, no space at either end. Null is no folder.
41
+ */
42
+ const folderCheck = (name: string, column: AnyPgColumn) =>
43
+ check(name, sql`${column} IS NULL OR (char_length(${column}) BETWEEN 1 AND 64 AND ${column} !~ '[/[:cntrl:]]' AND ${column} = btrim(${column}))`);
44
+
29
45
  export const projects = pgTable(
30
46
  'projects',
31
47
  {
@@ -34,10 +50,12 @@ export const projects = pgTable(
34
50
  name: text().notNull(),
35
51
  createdAt: createdAt(),
36
52
  archivedAt: timestamp('archived_at', { withTimezone: true }),
53
+ folder: text(),
37
54
  },
38
55
  (table) => [
39
56
  unique('projects_slug_key').on(table.slug),
40
- check('projects_slug_check', sql`${table.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
57
+ check('projects_slug_check', sql`${table.slug} ~ ${PLACE_SLUG}`),
58
+ folderCheck('projects_folder_check', table.folder),
41
59
  ],
42
60
  );
43
61
 
@@ -54,7 +72,7 @@ export const environments = pgTable(
54
72
  (table) => [
55
73
  unique('environments_project_id_slug_key').on(table.projectId, table.slug),
56
74
  unique('environments_project_scoped').on(table.id, table.projectId),
57
- check('environments_slug_check', sql`${table.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
75
+ check('environments_slug_check', sql`${table.slug} ~ ${PLACE_SLUG}`),
58
76
  foreignKey({
59
77
  name: 'environments_project_id_fkey',
60
78
  columns: [table.projectId],
@@ -80,6 +98,8 @@ export const secrets = pgTable(
80
98
  // append, so it is also the highest: the next one is this plus one.
81
99
  currentVersion: integer('current_version').notNull().default(0),
82
100
  archivedAt: timestamp('archived_at', { withTimezone: true }),
101
+ // Within its environment: the same key elsewhere has its own.
102
+ folder: text(),
83
103
  },
84
104
  (table) => [
85
105
  unique('secrets_project_id_environment_id_key_key').on(
@@ -88,6 +108,7 @@ export const secrets = pgTable(
88
108
  table.key,
89
109
  ),
90
110
  check('secrets_key_check', sql`${table.key} ~ '^[A-Za-z_][A-Za-z0-9_]{0,127}$'`),
111
+ folderCheck('secrets_folder_check', table.folder),
91
112
  foreignKey({
92
113
  name: 'secrets_project_id_fkey',
93
114
  columns: [table.projectId],
@@ -138,6 +159,34 @@ export const secretVersions = pgTable(
138
159
  ],
139
160
  );
140
161
 
162
+ /**
163
+ * Keys an environment is missing, which its team decided it does not need:
164
+ * the project's other environments have them, and its missing-keys list
165
+ * leaves them out until someone restores them (docs/design/environments.md).
166
+ * One row per environment and key, updated in place: restoring sets
167
+ * `restored_at`, dismissing again clears it.
168
+ */
169
+ export const dismissedKeys = pgTable(
170
+ 'dismissed_keys',
171
+ {
172
+ environmentId: uuid('environment_id').notNull(),
173
+ key: text().notNull(),
174
+ dismissedAt: timestamp('dismissed_at', { withTimezone: true }).notNull().defaultNow(),
175
+ dismissedBy: text('dismissed_by').notNull(),
176
+ restoredAt: timestamp('restored_at', { withTimezone: true }),
177
+ restoredBy: text('restored_by'),
178
+ },
179
+ (table) => [
180
+ primaryKey({ name: 'dismissed_keys_pkey', columns: [table.environmentId, table.key] }),
181
+ check('dismissed_keys_key_check', sql`${table.key} ~ '^[A-Za-z_][A-Za-z0-9_]{0,127}$'`),
182
+ foreignKey({
183
+ name: 'dismissed_keys_environment_id_fkey',
184
+ columns: [table.environmentId],
185
+ foreignColumns: [environments.id],
186
+ }).onDelete('restrict'),
187
+ ],
188
+ );
189
+
141
190
  /** The role catalogue, as SQL: every role, and those an environment may be granted. */
142
191
  const ROLES = sql.raw(ROLE_NAMES.map((role) => `'${role}'`).join(', '));
143
192
  const ENVIRONMENT_ROLES = sql.raw(ROLE_NAMES.filter(assignableToEnvironment).map((role) => `'${role}'`).join(', '));
@@ -146,7 +195,7 @@ const ENVIRONMENT_ROLES = sql.raw(ROLE_NAMES.filter(assignableToEnvironment).map
146
195
  * The vault's member directory: everyone it has admitted, written by the
147
196
  * vault alone, read by both. A principal with no row is no member.
148
197
  * Principals are the strings coffre uses at its edges: `user:<email>`,
149
- * `token:<id>`, `sync:<id>`. Times are milliseconds since the epoch, each
198
+ * `token:<id>`. Times are milliseconds since the epoch, each
150
199
  * the time of the vault's log entry that set it, so the log replays to
151
200
  * these rows exactly.
152
201
  */
@@ -174,7 +223,7 @@ export const vaultMembers = pgTable(
174
223
  },
175
224
  (table) => [
176
225
  check('vault_members_mac_check', sql`octet_length(${table.mac}) = 32`),
177
- check('vault_members_principal_check', sql`${table.principal} ~ '^(user|token|sync):[^[:space:]:][^[:space:]]*$'`),
226
+ check('vault_members_principal_check', sql`${table.principal} ~ '^(user|token):[^[:space:]:][^[:space:]]*$'`),
178
227
  // A person is their email address, lowercased, as in sign-in.
179
228
  check('vault_members_user_lowercase', sql`${table.principal} NOT LIKE 'user:%' OR ${table.principal} = lower(${table.principal})`),
180
229
  check('vault_members_status_check', sql`${table.status} IN ('active', 'removed')`),
@@ -184,9 +233,11 @@ export const vaultMembers = pgTable(
184
233
  );
185
234
 
186
235
  /**
187
- * One role per member per place: a project, or one of its environments,
188
- * never both. A revoked grant is deleted; an expired one stays until its
189
- * place is granted again, so the members page can say it lapsed.
236
+ * One role per member per place: a project, one of its environments, every
237
+ * project (`*`, neither id), or the environment of one slug in every project
238
+ * (neither id, and `environment_slug`). A revoked grant is deleted; an
239
+ * expired one stays until its place is granted again, so the members page
240
+ * can say it lapsed.
190
241
  */
191
242
  export const vaultGrants = pgTable(
192
243
  'vault_grants',
@@ -194,17 +245,29 @@ export const vaultGrants = pgTable(
194
245
  principal: text().notNull(),
195
246
  projectId: uuid('project_id'),
196
247
  environmentId: uuid('environment_id'),
248
+ environmentSlug: text('environment_slug'),
197
249
  role: text().notNull(),
198
250
  expiresAt: bigint('expires_at', { mode: 'number' }),
199
251
  grantedAt: bigint('granted_at', { mode: 'number' }).notNull(),
200
252
  grantedBy: text('granted_by').notNull(),
201
253
  },
202
254
  (table) => [
203
- check('vault_grants_one_place', sql`(${table.projectId} IS NULL) <> (${table.environmentId} IS NULL)`),
255
+ check(
256
+ 'vault_grants_one_place',
257
+ sql`((${table.projectId} IS NULL) <> (${table.environmentId} IS NULL) AND ${table.environmentSlug} IS NULL) OR (${table.projectId} IS NULL AND ${table.environmentId} IS NULL)`,
258
+ ),
259
+ check('vault_grants_environment_slug_check', sql`${table.environmentSlug} IS NULL OR ${table.environmentSlug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
204
260
  check('vault_grants_role_check', sql`${table.role} IN (${ROLES})`),
205
- check('vault_grants_environment_role_check', sql`${table.environmentId} IS NULL OR ${table.role} IN (${ENVIRONMENT_ROLES})`),
261
+ check(
262
+ 'vault_grants_environment_role_check',
263
+ sql`(${table.environmentId} IS NULL AND ${table.environmentSlug} IS NULL) OR ${table.role} IN (${ENVIRONMENT_ROLES})`,
264
+ ),
206
265
  unique('vault_grants_on_project').on(table.principal, table.projectId),
207
266
  unique('vault_grants_on_environment').on(table.principal, table.environmentId),
267
+ unique('vault_grants_on_environment_slug').on(table.principal, table.environmentSlug),
268
+ uniqueIndex('vault_grants_on_every_project')
269
+ .on(table.principal)
270
+ .where(sql`${table.projectId} IS NULL AND ${table.environmentId} IS NULL AND ${table.environmentSlug} IS NULL`),
208
271
  foreignKey({
209
272
  name: 'vault_grants_principal_fkey',
210
273
  columns: [table.principal],
@@ -234,7 +297,7 @@ export const auditLog = pgTable(
234
297
  // Milliseconds since the epoch, from the database's clock, read after the
235
298
  // append took the head's lock.
236
299
  occurredAt: bigint('occurred_at', { mode: 'number' }).notNull(),
237
- // Who acted: `user:<email>`, `token:<id>`, `sync:<id>` or `system:<name>`.
300
+ // Who acted: `user:<email>`, `token:<id>` or `system:<name>`.
238
301
  actor: text().notNull(),
239
302
  action: text().notNull(),
240
303
  decision: text().notNull(),
@@ -258,7 +321,7 @@ export const auditLog = pgTable(
258
321
  },
259
322
  (table) => [
260
323
  check('audit_log_author_check', sql`${table.author} IN ('app', 'vault')`),
261
- check('audit_log_actor_check', sql`${table.actor} ~ '^(user|token|sync|system):.+$'`),
324
+ check('audit_log_actor_check', sql`${table.actor} ~ '^(user|token|system):.+$'`),
262
325
  check('audit_log_decision_check', sql`${table.decision} IN ('allow', 'deny')`),
263
326
  check('audit_log_metadata_check', sql`${table.metadata}::jsonb IS NOT NULL`),
264
327
  check('audit_log_prev_hash_check', sql`octet_length(${table.prevHash}) = 32`),
@@ -308,6 +371,72 @@ export const auditLog = pgTable(
308
371
  index('audit_log_exchange_idx')
309
372
  .on(sql`((${table.metadata})::jsonb ->> 'credentialId')`)
310
373
  .where(sql`${table.author} = 'app' AND ${table.action} = 'token.exchange' AND ${table.decision} = 'allow'`),
374
+ // Whether a reference has ended: the vault's `reference.end` naming its creation.
375
+ index('audit_log_reference_end_idx')
376
+ .on(table.relatedSeq)
377
+ .where(sql`${table.author} = 'vault' AND ${table.action} = 'reference.end' AND ${table.decision} = 'allow'`),
378
+ // An entry about a reference names the other side, `also` in its
379
+ // metadata, so that it is in both projects' logs: a read through
380
+ // billing's reference is market's read, and billing's too.
381
+ index('audit_log_also_project_idx')
382
+ .on(sql`(((${table.metadata})::jsonb -> 'also') ->> 'projectId')`, table.seq)
383
+ .where(sql`((${table.metadata})::jsonb -> 'also') IS NOT NULL`),
384
+ index('audit_log_also_environment_idx')
385
+ .on(sql`(((${table.metadata})::jsonb -> 'also') ->> 'environmentId')`, table.seq)
386
+ .where(sql`((${table.metadata})::jsonb -> 'also') IS NOT NULL`),
387
+ index('audit_log_also_secret_idx')
388
+ .on(sql`(((${table.metadata})::jsonb -> 'also') ->> 'secretId')`, table.seq)
389
+ .where(sql`((${table.metadata})::jsonb -> 'also') IS NOT NULL`),
390
+ ],
391
+ );
392
+
393
+ /**
394
+ * A reference: a secret whose value is another's, read live through it by
395
+ * whoever reads the holder's environment (docs/design/environments.md).
396
+ * The row only points: the reference is the vault's `reference.create`
397
+ * entry at `created_seq`, which the vault checks at every read, and a
398
+ * `reference.end` naming that entry ends it, whatever this table says. So
399
+ * rows are only ever added.
400
+ */
401
+ export const secretReferences = pgTable(
402
+ 'secret_references',
403
+ {
404
+ id: uuid().primaryKey(),
405
+ // The holder: billing/prod/DATABASE_URL.
406
+ projectId: uuid('project_id').notNull(),
407
+ environmentId: uuid('environment_id').notNull(),
408
+ secretId: uuid('secret_id').notNull(),
409
+ // The source: market/prod/DATABASE_URL.
410
+ sourceProjectId: uuid('source_project_id').notNull(),
411
+ sourceEnvironmentId: uuid('source_environment_id').notNull(),
412
+ sourceSecretId: uuid('source_secret_id').notNull(),
413
+ createdSeq: bigint('created_seq', { mode: 'bigint' }).notNull(),
414
+ createdAt: createdAt(),
415
+ createdBy: text('created_by').notNull(),
416
+ },
417
+ (table) => [
418
+ unique('secret_references_created_seq_key').on(table.createdSeq),
419
+ foreignKey({
420
+ name: 'secret_references_secret_id_fkey',
421
+ columns: [table.secretId],
422
+ foreignColumns: [secrets.id],
423
+ }).onDelete('restrict'),
424
+ foreignKey({
425
+ name: 'secret_references_source_secret_id_fkey',
426
+ columns: [table.sourceSecretId],
427
+ foreignColumns: [secrets.id],
428
+ }).onDelete('restrict'),
429
+ foreignKey({
430
+ name: 'secret_references_created_seq_fkey',
431
+ columns: [table.createdSeq],
432
+ foreignColumns: [auditLog.seq],
433
+ }).onDelete('restrict'),
434
+ // A holder's newest reference; what reads its source; what a project lends; what someone made.
435
+ index('secret_references_holder_idx').on(table.secretId, table.createdSeq),
436
+ index('secret_references_source_idx').on(table.sourceSecretId),
437
+ index('secret_references_project_idx').on(table.projectId),
438
+ index('secret_references_source_project_idx').on(table.sourceProjectId),
439
+ index('secret_references_created_by_idx').on(table.createdBy),
311
440
  ],
312
441
  );
313
442
 
@@ -430,6 +559,129 @@ export const consumedTokens = pgTable(
430
559
  (table) => [check('consumed_tokens_hash_check', sql`octet_length(${table.hash}) = 32`)],
431
560
  );
432
561
 
562
+ /**
563
+ * MCP clients that registered themselves (RFC 7591), the fallback to Client
564
+ * ID Metadata Documents, which need no row. The ID is the client's
565
+ * `client_id`. Its redirects are under its MAC: a database writer cannot add
566
+ * one of their own to a client people have approved.
567
+ */
568
+ export const oauthClients = pgTable(
569
+ 'oauth_clients',
570
+ {
571
+ id: uuid().primaryKey(),
572
+ authMac: bytea('auth_mac').notNull(),
573
+ // Its own claim, shown as unverified.
574
+ name: text().notNull(),
575
+ // JSON: a list of HTTPS or loopback URLs.
576
+ redirectUris: text('redirect_uris').notNull(),
577
+ createdAt: createdAt(),
578
+ createdIp: text('created_ip'),
579
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
580
+ },
581
+ (table) => [
582
+ check('oauth_clients_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
583
+ check('oauth_clients_redirect_uris_check', sql`${table.redirectUris}::jsonb IS NOT NULL`),
584
+ ],
585
+ );
586
+
587
+ /**
588
+ * One MCP client a person connected: one approved authorization, its
589
+ * scopes, its code until the client redeems it, and the refresh token it
590
+ * holds since. Access tokens have no row: each names its connection under a
591
+ * MAC, and every request checks this row. Revoking it ends them all.
592
+ */
593
+ export const mcpConnections = pgTable(
594
+ 'mcp_connections',
595
+ {
596
+ id: uuid().primaryKey(),
597
+ authMac: bytea('auth_mac').notNull(),
598
+ // The person it acts as, `user:<email>`, under the generation they approved it in.
599
+ principal: text().notNull(),
600
+ generation: integer().notNull(),
601
+ // A metadata document's URL, or a registration's ID.
602
+ clientId: text('client_id').notNull(),
603
+ // As the consent page showed them: display only.
604
+ clientName: text('client_name').notNull(),
605
+ clientHost: text('client_host'),
606
+ registration: text().notNull(),
607
+ // Space-separated, in catalogue order: `browse write`.
608
+ scopes: text().notNull(),
609
+ redirectUri: text('redirect_uri').notNull(),
610
+ // Until the client redeems it: its SHA-256, its PKCE challenge, and 60 seconds.
611
+ codeHash: bytea('code_hash'),
612
+ codeChallenge: text('code_challenge'),
613
+ codeExpiresAt: timestamp('code_expires_at', { withTimezone: true }),
614
+ // From then on: the refresh token's SHA-256, and the one it replaced, whose reuse ends the connection.
615
+ refreshHash: bytea('refresh_hash'),
616
+ refreshPreviousHash: bytea('refresh_previous_hash'),
617
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
618
+ createdAt: createdAt(),
619
+ lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
620
+ lastUsedIp: text('last_used_ip'),
621
+ revokedAt: timestamp('revoked_at', { withTimezone: true }),
622
+ revokedBy: text('revoked_by'),
623
+ },
624
+ (table) => [
625
+ check('mcp_connections_principal_check', sql`${table.principal} LIKE 'user:%'`),
626
+ check('mcp_connections_registration_check', sql`${table.registration} IN ('cimd', 'dcr')`),
627
+ check('mcp_connections_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
628
+ check('mcp_connections_code_hash_check', sql`${table.codeHash} IS NULL OR octet_length(${table.codeHash}) = 32`),
629
+ check('mcp_connections_refresh_hash_check', sql`${table.refreshHash} IS NULL OR octet_length(${table.refreshHash}) = 32`),
630
+ foreignKey({
631
+ name: 'mcp_connections_principal_fkey',
632
+ columns: [table.principal],
633
+ foreignColumns: [vaultMembers.principal],
634
+ }).onDelete('restrict'),
635
+ unique('mcp_connections_code_hash_key').on(table.codeHash),
636
+ unique('mcp_connections_refresh_hash_key').on(table.refreshHash),
637
+ index('mcp_connections_refresh_previous_idx').on(table.refreshPreviousHash),
638
+ // A person's live connections: Connected apps, and the limit on how many.
639
+ index('mcp_connections_live_idx').on(table.principal, table.expiresAt).where(sql`${table.revokedAt} IS NULL`),
640
+ ],
641
+ );
642
+
643
+ /**
644
+ * A change an MCP client asked for, waiting for its person on coffre's own
645
+ * page (docs/design/mcp.md, section 7). It holds the tool and its arguments,
646
+ * never a value: the page shows what they would do and, on Approve, does it,
647
+ * once, as the person, through the connection. Its digest is of the tool
648
+ * and arguments, which the page sends back with the decision, so what runs
649
+ * is what the person read. Not MACed: a row changes nothing by itself.
650
+ */
651
+ export const mcpApprovals = pgTable(
652
+ 'mcp_approvals',
653
+ {
654
+ id: uuid().primaryKey(),
655
+ connectionId: uuid('connection_id').notNull(),
656
+ tool: text().notNull(),
657
+ // JSON: the tool's arguments, as checked when it was called.
658
+ arguments: text().notNull(),
659
+ // SHA-256 of the canonical JSON of the tool and its arguments.
660
+ digest: bytea().notNull(),
661
+ // pending, approved, denied, cancelled or failed; a pending one past its expiry has expired.
662
+ status: text().notNull().default('pending'),
663
+ // JSON: what the change answered, or why it failed. Never a value.
664
+ outcome: text(),
665
+ createdAt: createdAt(),
666
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
667
+ decidedAt: timestamp('decided_at', { withTimezone: true }),
668
+ // When the client read the outcome: an identical call after that asks again.
669
+ reportedAt: timestamp('reported_at', { withTimezone: true }),
670
+ },
671
+ (table) => [
672
+ check('mcp_approvals_status_check', sql`${table.status} IN ('pending', 'approved', 'denied', 'cancelled', 'failed')`),
673
+ check('mcp_approvals_digest_check', sql`octet_length(${table.digest}) = 32`),
674
+ check('mcp_approvals_arguments_check', sql`${table.arguments}::jsonb IS NOT NULL`),
675
+ foreignKey({
676
+ name: 'mcp_approvals_connection_fkey',
677
+ columns: [table.connectionId],
678
+ foreignColumns: [mcpConnections.id],
679
+ }).onDelete('restrict'),
680
+ // A connection's approvals its client has not yet heard the end of: the ones a call rejoins, and the five it may wait on.
681
+ index('mcp_approvals_open_idx').on(table.connectionId, table.digest).where(sql`${table.reportedAt} IS NULL`),
682
+ ],
683
+ );
684
+
433
685
  /**
434
686
  * Bearer credentials coffre issues itself: browser sessions, CLI sessions and
435
687
  * service tokens.
@@ -1,13 +0,0 @@
1
- -- Stop old app instances before upgrading. Never silently discard a configured
2
- -- destination, including archived ones. Keep the audit and sealed legacy rows.
3
- LOCK TABLE syncs, sync_keys IN ACCESS EXCLUSIVE MODE;
4
- --> statement-breakpoint
5
- DO $$ BEGIN
6
- IF EXISTS (SELECT 1 FROM syncs) OR EXISTS (SELECT 1 FROM sync_keys) THEN
7
- RAISE EXCEPTION 'syncs are removed: migrate destinations to service tokens, back up and clear syncs and sync_keys before upgrading';
8
- END IF;
9
- END $$;
10
- --> statement-breakpoint
11
- DROP TABLE sync_keys;
12
- --> statement-breakpoint
13
- DROP TABLE syncs;
@@ -1,28 +0,0 @@
1
- CREATE TABLE "service_bindings" (
2
- "id" uuid PRIMARY KEY NOT NULL,
3
- "auth_mac" "bytea" NOT NULL,
4
- "principal" text NOT NULL,
5
- "generation" integer NOT NULL,
6
- "profile" text NOT NULL,
7
- "issuer" text NOT NULL,
8
- "jwks_uri" text NOT NULL,
9
- "claims" text NOT NULL,
10
- "label" text,
11
- "created_at" timestamp with time zone DEFAULT now() NOT NULL,
12
- "created_by" text NOT NULL,
13
- "last_used_at" timestamp with time zone,
14
- "revoked_at" timestamp with time zone,
15
- "revoked_by" text,
16
- CONSTRAINT "service_bindings_principal_check" CHECK ("service_bindings"."principal" LIKE 'token:%'),
17
- CONSTRAINT "service_bindings_auth_mac_check" CHECK (octet_length("service_bindings"."auth_mac") = 32),
18
- CONSTRAINT "service_bindings_claims_check" CHECK ("service_bindings"."claims"::jsonb IS NOT NULL)
19
- );
20
- --> statement-breakpoint
21
- ALTER TABLE "service_bindings" ADD CONSTRAINT "service_bindings_principal_fkey" FOREIGN KEY ("principal") REFERENCES "public"."vault_members"("principal") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
22
- CREATE INDEX "service_bindings_principal_idx" ON "service_bindings" USING btree ("principal","issuer");--> statement-breakpoint
23
- CREATE INDEX "audit_log_unbind_idx" ON "audit_log" USING btree ((("metadata")::jsonb ->> 'bindingId')) WHERE "audit_log"."author" = 'app' AND "audit_log"."action" = 'token.unbind' AND "audit_log"."decision" = 'allow';--> statement-breakpoint
24
- -- The app reads and adds bindings, and changes only what the MAC allows to
25
- -- change in place: its label, its last use, and its revocation (with the
26
- -- MAC that covers it). A binding's policy is never updated, nor deleted.
27
- GRANT SELECT, INSERT ON service_bindings TO coffre_app;--> statement-breakpoint
28
- GRANT UPDATE (label, last_used_at, revoked_at, revoked_by, auth_mac) ON service_bindings TO coffre_app;
@@ -1,10 +0,0 @@
1
- CREATE TABLE "consumed_tokens" (
2
- "hash" "bytea" PRIMARY KEY NOT NULL,
3
- "consumed_at" timestamp with time zone DEFAULT now() NOT NULL,
4
- CONSTRAINT "consumed_tokens_hash_check" CHECK (octet_length("consumed_tokens"."hash") = 32)
5
- );
6
- --> statement-breakpoint
7
- CREATE INDEX "audit_log_exchange_idx" ON "audit_log" USING btree ((("metadata")::jsonb ->> 'credentialId')) WHERE "audit_log"."author" = 'app' AND "audit_log"."action" = 'token.exchange' AND "audit_log"."decision" = 'allow';--> statement-breakpoint
8
- -- The app records each token it exchanges, and reads whether one was; it
9
- -- never changes or deletes a record, so a token stays spent.
10
- GRANT SELECT, INSERT ON consumed_tokens TO coffre_app;
@@ -1,3 +0,0 @@
1
- CREATE INDEX "credentials_live_idx" ON "credentials" USING btree ("principal","expires_at") WHERE "credentials"."revoked_at" IS NULL;--> statement-breakpoint
2
- CREATE INDEX "credentials_issued_by_idx" ON "credentials" USING btree ("principal","created_by","created_at");--> statement-breakpoint
3
- CREATE INDEX "service_bindings_live_idx" ON "service_bindings" USING btree ("principal","issuer","generation","created_at","id") WHERE "service_bindings"."revoked_at" IS NULL;