@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.
- package/dist/connect.d.ts +1 -1
- package/dist/{database-Cq_52uUo.d.ts → database-edkMkG02.d.ts} +1 -1
- package/dist/dialect.d.ts +10 -2
- package/dist/dialect.js +34 -1
- package/dist/grants.d.ts +32 -0
- package/dist/grants.js +27 -0
- package/dist/hyperdrive.d.ts +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/log.d.ts +1 -1
- package/dist/migrate.js +6 -1
- package/dist/migrations/postgres/0000_baseline.sql +174 -46
- package/dist/migrations/postgres/0001_mcp_approvals.sql +24 -0
- package/dist/migrations/postgres/meta/0000_snapshot.json +963 -185
- package/dist/migrations/postgres/meta/0001_snapshot.json +1171 -30
- package/dist/migrations/postgres/meta/_journal.json +3 -24
- package/dist/migrations/sqlite/0000_baseline.sql +142 -35
- package/dist/migrations/sqlite/0001_mcp_approvals.sql +19 -0
- package/dist/migrations/sqlite/meta/0000_snapshot.json +878 -235
- package/dist/migrations/sqlite/meta/0001_snapshot.json +1062 -57
- package/dist/migrations/sqlite/meta/_journal.json +3 -24
- package/dist/portable.d.ts +2 -2
- package/dist/{schema-Wt-p8AOh.d.ts → schema-BqCB3XFm.d.ts} +1153 -83
- package/dist/schema-sqlite.d.ts +1171 -60
- package/dist/schema-sqlite.js +173 -11
- package/dist/schema-version.d.ts +9 -13
- package/dist/schema-version.js +38 -90
- package/dist/schema.d.ts +2 -2
- package/dist/schema.js +208 -15
- package/package.json +7 -2
- package/src/baseline/postgres.sql +46 -10
- package/src/baseline/sqlite.sql +15 -0
- package/src/baseline.ts +7 -3
- package/src/dialect.ts +25 -1
- package/src/grants.ts +51 -0
- package/src/migrate.ts +5 -0
- package/src/migrations/postgres/0000_baseline.sql +174 -46
- package/src/migrations/postgres/0001_mcp_approvals.sql +24 -0
- package/src/migrations/postgres/meta/0000_snapshot.json +963 -185
- package/src/migrations/postgres/meta/0001_snapshot.json +1171 -30
- package/src/migrations/postgres/meta/_journal.json +3 -24
- package/src/migrations/sqlite/0000_baseline.sql +142 -35
- package/src/migrations/sqlite/0001_mcp_approvals.sql +19 -0
- package/src/migrations/sqlite/meta/0000_snapshot.json +878 -235
- package/src/migrations/sqlite/meta/0001_snapshot.json +1062 -57
- package/src/migrations/sqlite/meta/_journal.json +3 -24
- package/src/schema-version.ts +26 -18
- package/src/schema.sqlite.ts +223 -8
- package/src/schema.ts +263 -11
- package/dist/migrations/postgres/0001_remove_syncs.sql +0 -13
- package/dist/migrations/postgres/0002_service_bindings.sql +0 -28
- package/dist/migrations/postgres/0003_exchanges.sql +0 -10
- package/dist/migrations/postgres/0004_live_indexes.sql +0 -3
- package/dist/migrations/postgres/meta/0002_snapshot.json +0 -1870
- package/dist/migrations/postgres/meta/0003_snapshot.json +0 -1917
- package/dist/migrations/postgres/meta/0004_snapshot.json +0 -2006
- package/dist/migrations/sqlite/0001_remove_syncs.sql +0 -13
- package/dist/migrations/sqlite/0002_service_bindings.sql +0 -23
- package/dist/migrations/sqlite/0003_exchanges.sql +0 -7
- package/dist/migrations/sqlite/0004_live_indexes.sql +0 -3
- package/dist/migrations/sqlite/meta/0002_snapshot.json +0 -1736
- package/dist/migrations/sqlite/meta/0003_snapshot.json +0 -1781
- package/dist/migrations/sqlite/meta/0004_snapshot.json +0 -1811
- package/src/migrations/postgres/0001_remove_syncs.sql +0 -13
- package/src/migrations/postgres/0002_service_bindings.sql +0 -28
- package/src/migrations/postgres/0003_exchanges.sql +0 -10
- package/src/migrations/postgres/0004_live_indexes.sql +0 -3
- package/src/migrations/postgres/meta/0002_snapshot.json +0 -1870
- package/src/migrations/postgres/meta/0003_snapshot.json +0 -1917
- package/src/migrations/postgres/meta/0004_snapshot.json +0 -2006
- package/src/migrations/sqlite/0001_remove_syncs.sql +0 -13
- package/src/migrations/sqlite/0002_service_bindings.sql +0 -23
- package/src/migrations/sqlite/0003_exchanges.sql +0 -7
- package/src/migrations/sqlite/0004_live_indexes.sql +0 -3
- package/src/migrations/sqlite/meta/0002_snapshot.json +0 -1736
- package/src/migrations/sqlite/meta/0003_snapshot.json +0 -1781
- 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} ~
|
|
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} ~
|
|
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
|
|
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
|
|
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,
|
|
188
|
-
*
|
|
189
|
-
*
|
|
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(
|
|
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(
|
|
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
|
|
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|
|
|
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;
|