@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.
- 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/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
|
-
|
|
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} ~
|
|
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
|
|
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
|
|
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,
|
|
138
|
-
*
|
|
139
|
-
*
|
|
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
|
|
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|
|
|
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
|
+
"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.
|
|
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
|
|
35
|
-
--
|
|
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
|
-
|
|
127
|
-
|
|
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 (
|
|
154
|
-
|
|
155
|
-
|
|
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 (
|
|
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
|
|
package/src/baseline/sqlite.sql
CHANGED
|
@@ -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,
|
|
24
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
);
|