@coffre/db 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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
@@ -5,36 +5,15 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "6",
8
- "when": 1790874462505,
8
+ "when": 1791237724735,
9
9
  "tag": "0000_baseline",
10
10
  "breakpoints": true
11
11
  },
12
12
  {
13
13
  "idx": 1,
14
14
  "version": "6",
15
- "when": 1791062293531,
16
- "tag": "0001_remove_syncs",
17
- "breakpoints": true
18
- },
19
- {
20
- "idx": 2,
21
- "version": "6",
22
- "when": 1791069305749,
23
- "tag": "0002_service_bindings",
24
- "breakpoints": true
25
- },
26
- {
27
- "idx": 3,
28
- "version": "6",
29
- "when": 1791072337548,
30
- "tag": "0003_exchanges",
31
- "breakpoints": true
32
- },
33
- {
34
- "idx": 4,
35
- "version": "6",
36
- "when": 1791084355588,
37
- "tag": "0004_live_indexes",
15
+ "when": 1791242675074,
16
+ "tag": "0001_mcp_approvals",
38
17
  "breakpoints": true
39
18
  }
40
19
  ]
@@ -1,32 +1,40 @@
1
+ import { count, sql, type SQL } from 'drizzle-orm';
2
+
1
3
  import type { Queryable } from './database.ts';
2
- import { engineOf, type Engine } from './dialect.ts';
4
+ import { engineOf, migrationLedger, type Engine } from './dialect.ts';
3
5
  import postgresJournal from './migrations/postgres/meta/_journal.json' with { type: 'json' };
4
6
  import sqliteJournal from './migrations/sqlite/meta/_journal.json' with { type: 'json' };
5
7
 
6
- /**
7
- * Minimum migration prefix required by this application image, per engine:
8
- * Advance this only when the runtime needs the new schema. The sync-removal
9
- * migration only drops unused tables: new code works with either prefix,
10
- * so Workers Builds can deploy before an owner runs the migration.
11
- * Applied migrations remain immutable (see baseline.ts).
12
- */
13
- export const REQUIRED_MIGRATIONS: Record<Engine, number> = { postgres: 1, sqlite: 1 };
14
-
15
- export function requiredMigrations(db: Queryable): number {
16
- return REQUIRED_MIGRATIONS[engineOf(db)];
17
- }
18
-
19
8
  /**
20
9
  * Every migration this version ships, oldest first, per engine: read from
21
10
  * the journals at build time, so a Worker, which has no files, knows them
22
- * too. A database that has applied fewer is behind this code, whatever
23
- * `REQUIRED_MIGRATIONS` lets it run on.
11
+ * too.
24
12
  */
25
13
  export const KNOWN_MIGRATIONS: Record<Engine, readonly string[]> = {
26
14
  postgres: postgresJournal.entries.map((entry) => entry.tag),
27
15
  sqlite: sqliteJournal.entries.map((entry) => entry.tag),
28
16
  };
29
17
 
30
- export function knownMigrations(db: Queryable): readonly string[] {
31
- return KNOWN_MIGRATIONS[engineOf(db)];
18
+ /**
19
+ * Whether the database has applied every migration this version ships:
20
+ * below that, the app serves nothing but its readiness, which says so
21
+ * (`coffre migrate` runs before each deploy). A database never loses a
22
+ * migration, so once true it stays true.
23
+ */
24
+ export async function migrated(db: Queryable): Promise<boolean> {
25
+ // A database never migrated has no ledger at all, which is no migration, not an error.
26
+ if (!(await hasLedger(db))) return false;
27
+ const [ledger] = await db.select({ n: count() }).from(migrationLedger(db));
28
+ return (ledger?.n ?? 0) >= KNOWN_MIGRATIONS[engineOf(db)].length;
29
+ }
30
+
31
+ async function hasLedger(db: Queryable): Promise<boolean> {
32
+ if (engineOf(db) === 'postgres') {
33
+ const { rows } = await db.execute<{ present: boolean }>(sql`SELECT to_regclass('drizzle.__drizzle_migrations') IS NOT NULL AS present`);
34
+ return rows[0]?.present === true;
35
+ }
36
+ const rows = await (db as unknown as { all<R>(query: SQL): Promise<R[]> }).all<{ present: number }>(
37
+ sql`SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = '__drizzle_migrations'`,
38
+ );
39
+ return rows.length > 0;
32
40
  }
@@ -48,6 +48,17 @@ const flag = (name: string) => integer(name, { mode: 'boolean' });
48
48
  const isSlug = (column: AnySQLiteColumn, max: number) =>
49
49
  sql`length(${column}) BETWEEN 1 AND ${sql.raw(String(max))} AND ${column} GLOB '[a-z0-9]*' AND ${column} NOT GLOB '*[^a-z0-9-]*'`;
50
50
 
51
+ /** A slug, or a tombstone's, `market~deleted-2026-10-05`: schema.ts's `PLACE_SLUG`, in GLOB. */
52
+ const isPlaceSlug = (column: AnySQLiteColumn) => {
53
+ const base = sql`substr(${column}, 1, instr(${column}, '~') - 1)`;
54
+ const suffix = sql`substr(${column}, instr(${column}, '~') + 1)`;
55
+ return sql`(${isSlug(column, 63)}) OR (instr(${column}, '~') BETWEEN 2 AND 64 AND ${base} GLOB '[a-z0-9]*' AND ${base} NOT GLOB '*[^a-z0-9-]*' AND length(${suffix}) BETWEEN 1 AND 40 AND ${suffix} NOT GLOB '*[^a-z0-9-]*')`;
56
+ };
57
+
58
+ /** A folder's name; the app also refuses control characters, which GLOB has no class for. */
59
+ const folderCheck = (name: string, column: AnySQLiteColumn) =>
60
+ check(name, sql`${column} IS NULL OR (length(${column}) BETWEEN 1 AND 64 AND ${column} NOT GLOB '*/*' AND ${column} = trim(${column}))`);
61
+
51
62
  export const projects = sqliteTable(
52
63
  'projects',
53
64
  {
@@ -56,10 +67,12 @@ export const projects = sqliteTable(
56
67
  name: text().notNull(),
57
68
  createdAt: createdAt(),
58
69
  archivedAt: time('archived_at'),
70
+ folder: text(),
59
71
  },
60
72
  (table) => [
61
73
  unique('projects_slug_key').on(table.slug),
62
- check('projects_slug_check', isSlug(table.slug, 63)),
74
+ check('projects_slug_check', isPlaceSlug(table.slug)),
75
+ folderCheck('projects_folder_check', table.folder),
63
76
  ],
64
77
  );
65
78
 
@@ -76,7 +89,7 @@ export const environments = sqliteTable(
76
89
  (table) => [
77
90
  unique('environments_project_id_slug_key').on(table.projectId, table.slug),
78
91
  unique('environments_project_scoped').on(table.id, table.projectId),
79
- check('environments_slug_check', isSlug(table.slug, 63)),
92
+ check('environments_slug_check', isPlaceSlug(table.slug)),
80
93
  foreignKey({
81
94
  name: 'environments_project_id_fkey',
82
95
  columns: [table.projectId],
@@ -99,6 +112,7 @@ export const secrets = sqliteTable(
99
112
  }),
100
113
  currentVersion: integer('current_version').notNull().default(0),
101
114
  archivedAt: time('archived_at'),
115
+ folder: text(),
102
116
  },
103
117
  (table) => [
104
118
  unique('secrets_project_id_environment_id_key_key').on(table.projectId, table.environmentId, table.key),
@@ -106,6 +120,7 @@ export const secrets = sqliteTable(
106
120
  'secrets_key_check',
107
121
  sql`length(${table.key}) BETWEEN 1 AND 128 AND ${table.key} GLOB '[A-Za-z_]*' AND ${table.key} NOT GLOB '*[^A-Za-z0-9_]*'`,
108
122
  ),
123
+ folderCheck('secrets_folder_check', table.folder),
109
124
  foreignKey({
110
125
  name: 'secrets_project_id_fkey',
111
126
  columns: [table.projectId],
@@ -156,6 +171,30 @@ export const secretVersions = sqliteTable(
156
171
  ],
157
172
  );
158
173
 
174
+ export const dismissedKeys = sqliteTable(
175
+ 'dismissed_keys',
176
+ {
177
+ environmentId: text('environment_id').notNull(),
178
+ key: text().notNull(),
179
+ dismissedAt: time('dismissed_at').notNull().default(now),
180
+ dismissedBy: text('dismissed_by').notNull(),
181
+ restoredAt: time('restored_at'),
182
+ restoredBy: text('restored_by'),
183
+ },
184
+ (table) => [
185
+ primaryKey({ name: 'dismissed_keys_pkey', columns: [table.environmentId, table.key] }),
186
+ check(
187
+ 'dismissed_keys_key_check',
188
+ sql`length(${table.key}) BETWEEN 1 AND 128 AND ${table.key} GLOB '[A-Za-z_]*' AND ${table.key} NOT GLOB '*[^A-Za-z0-9_]*'`,
189
+ ),
190
+ foreignKey({
191
+ name: 'dismissed_keys_environment_id_fkey',
192
+ columns: [table.environmentId],
193
+ foreignColumns: [environments.id],
194
+ }).onDelete('restrict'),
195
+ ],
196
+ );
197
+
159
198
  const ROLES = sql.raw(ROLE_NAMES.map((role) => `'${role}'`).join(', '));
160
199
  const ENVIRONMENT_ROLES = sql.raw(ROLE_NAMES.filter(assignableToEnvironment).map((role) => `'${role}'`).join(', '));
161
200
 
@@ -175,10 +214,10 @@ export const vaultMembers = sqliteTable(
175
214
  },
176
215
  (table) => [
177
216
  check('vault_members_mac_check', sql`octet_length(${table.mac}) = 32`),
178
- // `^(user|token|sync):[^[:space:]:][^[:space:]]*$`, without regular expressions.
217
+ // `^(user|token):[^[:space:]:][^[:space:]]*$`, without regular expressions.
179
218
  check(
180
219
  'vault_members_principal_check',
181
- sql`(${table.principal} GLOB 'user:?*' OR ${table.principal} GLOB 'token:?*' OR ${table.principal} GLOB 'sync:?*')
220
+ sql`(${table.principal} GLOB 'user:?*' OR ${table.principal} GLOB 'token:?*')
182
221
  AND substr(${table.principal}, instr(${table.principal}, ':') + 1, 1) <> ':'
183
222
  AND instr(${table.principal}, ' ') = 0 AND instr(${table.principal}, char(9)) = 0
184
223
  AND instr(${table.principal}, char(10)) = 0 AND instr(${table.principal}, char(13)) = 0`,
@@ -196,17 +235,29 @@ export const vaultGrants = sqliteTable(
196
235
  principal: text().notNull(),
197
236
  projectId: text('project_id'),
198
237
  environmentId: text('environment_id'),
238
+ environmentSlug: text('environment_slug'),
199
239
  role: text().notNull(),
200
240
  expiresAt: integer('expires_at', { mode: 'number' }),
201
241
  grantedAt: integer('granted_at', { mode: 'number' }).notNull(),
202
242
  grantedBy: text('granted_by').notNull(),
203
243
  },
204
244
  (table) => [
205
- check('vault_grants_one_place', sql`(${table.projectId} IS NULL) <> (${table.environmentId} IS NULL)`),
245
+ check(
246
+ 'vault_grants_one_place',
247
+ sql`((${table.projectId} IS NULL) <> (${table.environmentId} IS NULL) AND ${table.environmentSlug} IS NULL) OR (${table.projectId} IS NULL AND ${table.environmentId} IS NULL)`,
248
+ ),
249
+ check('vault_grants_environment_slug_check', sql`${table.environmentSlug} IS NULL OR (${isSlug(table.environmentSlug, 63)})`),
206
250
  check('vault_grants_role_check', sql`${table.role} IN (${ROLES})`),
207
- check('vault_grants_environment_role_check', sql`${table.environmentId} IS NULL OR ${table.role} IN (${ENVIRONMENT_ROLES})`),
251
+ check(
252
+ 'vault_grants_environment_role_check',
253
+ sql`(${table.environmentId} IS NULL AND ${table.environmentSlug} IS NULL) OR ${table.role} IN (${ENVIRONMENT_ROLES})`,
254
+ ),
208
255
  unique('vault_grants_on_project').on(table.principal, table.projectId),
209
256
  unique('vault_grants_on_environment').on(table.principal, table.environmentId),
257
+ unique('vault_grants_on_environment_slug').on(table.principal, table.environmentSlug),
258
+ uniqueIndex('vault_grants_on_every_project')
259
+ .on(table.principal)
260
+ .where(sql`${table.projectId} IS NULL AND ${table.environmentId} IS NULL AND ${table.environmentSlug} IS NULL`),
210
261
  foreignKey({
211
262
  name: 'vault_grants_principal_fkey',
212
263
  columns: [table.principal],
@@ -236,7 +287,7 @@ export const auditLog = sqliteTable(
236
287
  // Milliseconds since the epoch, from the database's clock, read after the
237
288
  // append took the head's lock.
238
289
  occurredAt: integer('occurred_at', { mode: 'number' }).notNull(),
239
- // Who acted: `user:<email>`, `token:<id>`, `sync:<id>` or `system:<name>`.
290
+ // Who acted: `user:<email>`, `token:<id>` or `system:<name>`.
240
291
  actor: text().notNull(),
241
292
  action: text().notNull(),
242
293
  decision: text().notNull(),
@@ -262,7 +313,7 @@ export const auditLog = sqliteTable(
262
313
  check('audit_log_author_check', sql`${table.author} IN ('app', 'vault')`),
263
314
  check(
264
315
  'audit_log_actor_check',
265
- sql`${table.actor} GLOB 'user:?*' OR ${table.actor} GLOB 'token:?*' OR ${table.actor} GLOB 'sync:?*' OR ${table.actor} GLOB 'system:?*'`,
316
+ sql`${table.actor} GLOB 'user:?*' OR ${table.actor} GLOB 'token:?*' OR ${table.actor} GLOB 'system:?*'`,
266
317
  ),
267
318
  check('audit_log_decision_check', sql`${table.decision} IN ('allow', 'deny')`),
268
319
  check('audit_log_metadata_check', sql`json_valid(${table.metadata})`),
@@ -311,6 +362,57 @@ export const auditLog = sqliteTable(
311
362
  index('audit_log_exchange_idx')
312
363
  .on(sql`json_extract(${table.metadata}, '$.credentialId')`)
313
364
  .where(sql`${table.author} = 'app' AND ${table.action} = 'token.exchange' AND ${table.decision} = 'allow'`),
365
+ index('audit_log_reference_end_idx')
366
+ .on(table.relatedSeq)
367
+ .where(sql`${table.author} = 'vault' AND ${table.action} = 'reference.end' AND ${table.decision} = 'allow'`),
368
+ index('audit_log_also_project_idx')
369
+ .on(sql`json_extract(${table.metadata}, '$.also.projectId')`, table.seq)
370
+ .where(sql`json_extract(${table.metadata}, '$.also') IS NOT NULL`),
371
+ index('audit_log_also_environment_idx')
372
+ .on(sql`json_extract(${table.metadata}, '$.also.environmentId')`, table.seq)
373
+ .where(sql`json_extract(${table.metadata}, '$.also') IS NOT NULL`),
374
+ index('audit_log_also_secret_idx')
375
+ .on(sql`json_extract(${table.metadata}, '$.also.secretId')`, table.seq)
376
+ .where(sql`json_extract(${table.metadata}, '$.also') IS NOT NULL`),
377
+ ],
378
+ );
379
+
380
+ export const secretReferences = sqliteTable(
381
+ 'secret_references',
382
+ {
383
+ id: text().primaryKey(),
384
+ projectId: text('project_id').notNull(),
385
+ environmentId: text('environment_id').notNull(),
386
+ secretId: text('secret_id').notNull(),
387
+ sourceProjectId: text('source_project_id').notNull(),
388
+ sourceEnvironmentId: text('source_environment_id').notNull(),
389
+ sourceSecretId: text('source_secret_id').notNull(),
390
+ createdSeq: int64('created_seq').notNull(),
391
+ createdAt: createdAt(),
392
+ createdBy: text('created_by').notNull(),
393
+ },
394
+ (table) => [
395
+ unique('secret_references_created_seq_key').on(table.createdSeq),
396
+ foreignKey({
397
+ name: 'secret_references_secret_id_fkey',
398
+ columns: [table.secretId],
399
+ foreignColumns: [secrets.id],
400
+ }).onDelete('restrict'),
401
+ foreignKey({
402
+ name: 'secret_references_source_secret_id_fkey',
403
+ columns: [table.sourceSecretId],
404
+ foreignColumns: [secrets.id],
405
+ }).onDelete('restrict'),
406
+ foreignKey({
407
+ name: 'secret_references_created_seq_fkey',
408
+ columns: [table.createdSeq],
409
+ foreignColumns: [auditLog.seq],
410
+ }).onDelete('restrict'),
411
+ index('secret_references_holder_idx').on(table.secretId, table.createdSeq),
412
+ index('secret_references_source_idx').on(table.sourceSecretId),
413
+ index('secret_references_project_idx').on(table.projectId),
414
+ index('secret_references_source_project_idx').on(table.sourceProjectId),
415
+ index('secret_references_created_by_idx').on(table.createdBy),
314
416
  ],
315
417
  );
316
418
 
@@ -410,6 +512,119 @@ export const consumedTokens = sqliteTable(
410
512
  (table) => [check('consumed_tokens_hash_check', sql`octet_length(${table.hash}) = 32`)],
411
513
  );
412
514
 
515
+ /**
516
+ * MCP clients that registered themselves (RFC 7591), the fallback to Client
517
+ * ID Metadata Documents, which need no row. The ID is the client's
518
+ * `client_id`. Its redirects are under its MAC: a database writer cannot add
519
+ * one of their own to a client people have approved.
520
+ */
521
+ export const oauthClients = sqliteTable(
522
+ 'oauth_clients',
523
+ {
524
+ id: text().primaryKey(),
525
+ authMac: bytes('auth_mac').notNull(),
526
+ // Its own claim, shown as unverified.
527
+ name: text().notNull(),
528
+ // JSON: a list of HTTPS or loopback URLs.
529
+ redirectUris: text('redirect_uris').notNull(),
530
+ createdAt: createdAt(),
531
+ createdIp: text('created_ip'),
532
+ revokedAt: time('revoked_at'),
533
+ },
534
+ (table) => [
535
+ check('oauth_clients_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
536
+ check('oauth_clients_redirect_uris_check', sql`json_valid(${table.redirectUris})`),
537
+ ],
538
+ );
539
+
540
+ /**
541
+ * One MCP client a person connected: one approved authorization, its
542
+ * scopes, its code until the client redeems it, and the refresh token it
543
+ * holds since. Access tokens have no row: each names its connection under a
544
+ * MAC, and every request checks this row. Revoking it ends them all.
545
+ */
546
+ export const mcpConnections = sqliteTable(
547
+ 'mcp_connections',
548
+ {
549
+ id: text().primaryKey(),
550
+ authMac: bytes('auth_mac').notNull(),
551
+ // The person it acts as, `user:<email>`, under the generation they approved it in.
552
+ principal: text().notNull(),
553
+ generation: integer().notNull(),
554
+ // A metadata document's URL, or a registration's ID.
555
+ clientId: text('client_id').notNull(),
556
+ // As the consent page showed them: display only.
557
+ clientName: text('client_name').notNull(),
558
+ clientHost: text('client_host'),
559
+ registration: text().notNull(),
560
+ // Space-separated, in catalogue order: `browse write`.
561
+ scopes: text().notNull(),
562
+ redirectUri: text('redirect_uri').notNull(),
563
+ // Until the client redeems it: its SHA-256, its PKCE challenge, and 60 seconds.
564
+ codeHash: bytes('code_hash'),
565
+ codeChallenge: text('code_challenge'),
566
+ codeExpiresAt: time('code_expires_at'),
567
+ // From then on: the refresh token's SHA-256, and the one it replaced, whose reuse ends the connection.
568
+ refreshHash: bytes('refresh_hash'),
569
+ refreshPreviousHash: bytes('refresh_previous_hash'),
570
+ expiresAt: time('expires_at').notNull(),
571
+ createdAt: createdAt(),
572
+ lastUsedAt: time('last_used_at'),
573
+ lastUsedIp: text('last_used_ip'),
574
+ revokedAt: time('revoked_at'),
575
+ revokedBy: text('revoked_by'),
576
+ },
577
+ (table) => [
578
+ check('mcp_connections_principal_check', sql`${table.principal} LIKE 'user:%'`),
579
+ check('mcp_connections_registration_check', sql`${table.registration} IN ('cimd', 'dcr')`),
580
+ check('mcp_connections_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
581
+ check('mcp_connections_code_hash_check', sql`${table.codeHash} IS NULL OR octet_length(${table.codeHash}) = 32`),
582
+ check('mcp_connections_refresh_hash_check', sql`${table.refreshHash} IS NULL OR octet_length(${table.refreshHash}) = 32`),
583
+ foreignKey({
584
+ name: 'mcp_connections_principal_fkey',
585
+ columns: [table.principal],
586
+ foreignColumns: [vaultMembers.principal],
587
+ }).onDelete('restrict'),
588
+ unique('mcp_connections_code_hash_key').on(table.codeHash),
589
+ unique('mcp_connections_refresh_hash_key').on(table.refreshHash),
590
+ index('mcp_connections_refresh_previous_idx').on(table.refreshPreviousHash),
591
+ // A person's live connections: Connected apps, and the limit on how many.
592
+ index('mcp_connections_live_idx').on(table.principal, table.expiresAt).where(sql`${table.revokedAt} IS NULL`),
593
+ ],
594
+ );
595
+
596
+ /**
597
+ * A change an MCP client asked for, waiting for its person on coffre's own
598
+ * page: see schema.ts.
599
+ */
600
+ export const mcpApprovals = sqliteTable(
601
+ 'mcp_approvals',
602
+ {
603
+ id: text().primaryKey(),
604
+ connectionId: text('connection_id').notNull(),
605
+ tool: text().notNull(),
606
+ arguments: text().notNull(),
607
+ digest: bytes('digest').notNull(),
608
+ status: text().notNull().default('pending'),
609
+ outcome: text(),
610
+ createdAt: createdAt(),
611
+ expiresAt: time('expires_at').notNull(),
612
+ decidedAt: time('decided_at'),
613
+ reportedAt: time('reported_at'),
614
+ },
615
+ (table) => [
616
+ check('mcp_approvals_status_check', sql`${table.status} IN ('pending', 'approved', 'denied', 'cancelled', 'failed')`),
617
+ check('mcp_approvals_digest_check', sql`octet_length(${table.digest}) = 32`),
618
+ check('mcp_approvals_arguments_check', sql`json_valid(${table.arguments})`),
619
+ foreignKey({
620
+ name: 'mcp_approvals_connection_fkey',
621
+ columns: [table.connectionId],
622
+ foreignColumns: [mcpConnections.id],
623
+ }).onDelete('restrict'),
624
+ index('mcp_approvals_open_idx').on(table.connectionId, table.digest).where(sql`${table.reportedAt} IS NULL`),
625
+ ],
626
+ );
627
+
413
628
  export const credentials = sqliteTable(
414
629
  'credentials',
415
630
  {