@coffre/db 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -3
  3. package/dist/commits-BuW-NL3n.js +30 -0
  4. package/dist/connect.d.ts +26 -0
  5. package/dist/connect.js +156 -0
  6. package/dist/database-CWpo5aF0.d.ts +39 -0
  7. package/dist/dialect.d.ts +88 -0
  8. package/dist/dialect.js +128 -0
  9. package/dist/hyperdrive.d.ts +32 -0
  10. package/dist/hyperdrive.js +54 -0
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.js +34 -0
  13. package/dist/log.d.ts +72 -0
  14. package/dist/log.js +133 -0
  15. package/dist/migrate.d.ts +10 -0
  16. package/dist/migrate.js +130 -0
  17. package/dist/migrations/postgres/0000_baseline.sql +505 -0
  18. package/dist/migrations/postgres/meta/0000_snapshot.json +1939 -0
  19. package/dist/migrations/postgres/meta/_journal.json +13 -0
  20. package/dist/migrations/sqlite/0000_baseline.sql +293 -0
  21. package/dist/migrations/sqlite/meta/0000_snapshot.json +1811 -0
  22. package/dist/migrations/sqlite/meta/_journal.json +13 -0
  23. package/dist/portable.d.ts +33 -0
  24. package/dist/portable.js +12 -0
  25. package/dist/postgres-Bmar8y0q.js +29 -0
  26. package/dist/relations-DqgxtlKu.js +58 -0
  27. package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
  28. package/dist/schema-Cd4WOnB5.d.ts +2503 -0
  29. package/dist/schema-sqlite.d.ts +2587 -0
  30. package/dist/schema-sqlite.js +401 -0
  31. package/dist/schema-version.d.ts +11 -0
  32. package/dist/schema-version.js +16 -0
  33. package/dist/schema.d.ts +2 -0
  34. package/dist/schema.js +448 -0
  35. package/package.json +92 -5
  36. package/src/baseline/postgres.sql +241 -0
  37. package/src/baseline/sqlite.sql +27 -0
  38. package/src/baseline.ts +104 -0
  39. package/src/commits.ts +47 -0
  40. package/src/connect.ts +171 -0
  41. package/src/database.ts +63 -0
  42. package/src/dialect.ts +174 -0
  43. package/src/drizzle.postgres.config.ts +7 -0
  44. package/src/drizzle.sqlite.config.ts +7 -0
  45. package/src/hyperdrive.ts +63 -0
  46. package/src/log.ts +177 -0
  47. package/src/migrate-main.ts +8 -0
  48. package/src/migrate.ts +207 -0
  49. package/src/migrations/postgres/0000_baseline.sql +505 -0
  50. package/src/migrations/postgres/meta/0000_snapshot.json +1939 -0
  51. package/src/migrations/postgres/meta/_journal.json +13 -0
  52. package/src/migrations/sqlite/0000_baseline.sql +293 -0
  53. package/src/migrations/sqlite/meta/0000_snapshot.json +1811 -0
  54. package/src/migrations/sqlite/meta/_journal.json +13 -0
  55. package/src/portable.ts +54 -0
  56. package/src/postgres.ts +35 -0
  57. package/src/relations.ts +53 -0
  58. package/src/schema-version.ts +13 -0
  59. package/src/schema.sqlite.ts +511 -0
  60. package/src/schema.ts +548 -0
  61. package/src/verify-runtime.ts +122 -0
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1790874462505,
9
+ "tag": "0000_baseline",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,54 @@
1
+ import type { LibSQLDatabase } from 'drizzle-orm/libsql';
2
+
3
+ import { trackCommits } from './commits.ts';
4
+ import type { Database } from './database.ts';
5
+ import type * as postgres from './schema.ts';
6
+ import type * as sqlite from './schema.sqlite.ts';
7
+
8
+ /**
9
+ * The one place a SQLite database becomes a Postgres one.
10
+ *
11
+ * Drizzle has no type that spans dialects, so the queries are written once,
12
+ * against the Postgres schema, and a SQLite database is handed to
13
+ * them under the Postgres type. That holds because both schemas have the
14
+ * same tables with the same row types (checked below, at compile time, and
15
+ * in schema-parity.test.ts, at run time), and because every query runs on
16
+ * the database's own tables: queries.ts reads them from the database it is
17
+ * given (`tablesOf`), never from an import. A Postgres column on a SQLite
18
+ * database would encode its values the Postgres way.
19
+ */
20
+
21
+ export type PostgresSchema = typeof postgres;
22
+
23
+ /** Some of a schema's tables, read as their Postgres twins. */
24
+ export function asPostgres<Tables extends Partial<Record<keyof PostgresSchema, unknown>>>(
25
+ tables: Tables,
26
+ ): Pick<PostgresSchema, keyof Tables & keyof PostgresSchema> {
27
+ return tables as never;
28
+ }
29
+
30
+ /** A SQLite database, read as a Postgres one. */
31
+ export function asPostgresDatabase(
32
+ db: LibSQLDatabase<typeof sqlite>,
33
+ ): Database {
34
+ return trackCommits(db as unknown as Database);
35
+ }
36
+
37
+ // --- the row types match ---------------------------------------------------------
38
+
39
+ type TableName = {
40
+ [K in keyof PostgresSchema]: PostgresSchema[K] extends { $inferSelect: object } ? K : never;
41
+ }[keyof PostgresSchema];
42
+
43
+ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
44
+
45
+ /** The tables whose rows differ from their Postgres twin's; never if none. */
46
+ type Mismatched<Schema extends Record<TableName, { $inferSelect: object }>> = {
47
+ [K in TableName]: Equal<Schema[K]['$inferSelect'], PostgresSchema[K]['$inferSelect']> extends true ? never : K;
48
+ }[TableName];
49
+
50
+ type None<T extends never> = T;
51
+
52
+ // A column that differs in type, nullability or name breaks the typecheck
53
+ // here, naming its table.
54
+ export type SqliteRowsMatch = None<Mismatched<typeof sqlite>>;
@@ -0,0 +1,35 @@
1
+ import { checkServerIdentity } from 'node:tls';
2
+
3
+ import { parseIntoClientConfig } from 'pg-connection-string';
4
+ import type { ClientConfig } from 'pg';
5
+
6
+ /** Keep libpq's system-root shortcut from being read as a certificate filename by pg. */
7
+ export function postgresConnection(url: string): ClientConfig {
8
+ let parsed: URL;
9
+ try {
10
+ parsed = new URL(url);
11
+ } catch {
12
+ // A parse error must not print the URL's password.
13
+ throw new Error('invalid Postgres URL');
14
+ }
15
+ const parameters = parsed.searchParams;
16
+ if (parameters.getAll('sslrootcert').at(-1) !== 'system') return { connectionString: url };
17
+ const mode = parameters.getAll('sslmode').at(-1);
18
+ if (mode !== undefined && mode !== 'verify-full') {
19
+ throw new Error('sslrootcert=system requires sslmode=verify-full');
20
+ }
21
+ parameters.delete('sslrootcert');
22
+ parameters.set('sslmode', 'verify-full');
23
+ const config = parseIntoClientConfig(parsed.toString());
24
+ const host = config.host;
25
+ if (!host) throw new Error('sslrootcert=system requires a Postgres hostname');
26
+ // Do not pass a connectionString too: pg would replace these SSL options.
27
+ // With no CA override, Node uses its default trusted roots. pg omits SNI
28
+ // for IP addresses, so bind verification to the actual host explicitly.
29
+ config.ssl = {
30
+ ...config.ssl as object,
31
+ rejectUnauthorized: true,
32
+ checkServerIdentity: (_hostname, certificate) => checkServerIdentity(host, certificate),
33
+ };
34
+ return config;
35
+ }
@@ -0,0 +1,53 @@
1
+ import { relations, sql } from 'drizzle-orm';
2
+
3
+ import type * as schema from './schema.ts';
4
+
5
+ /**
6
+ * What both schemas share beyond their columns: the relations and the
7
+ * one generated column.
8
+ *
9
+ * Relations are for Drizzle's relational queries (`db.query.syncs
10
+ * .findMany({ with })`), which read a row and what hangs off it in one
11
+ * statement on every dialect. They add no constraints; the foreign keys in
12
+ * each schema do that. Each schema calls this with its own tables, so a
13
+ * relational query on SQLite joins SQLite tables.
14
+ */
15
+ export function relationsOf(t: Pick<
16
+ typeof schema,
17
+ | 'projects'
18
+ | 'environments'
19
+ | 'secrets'
20
+ | 'secretVersions'
21
+ | 'syncs'
22
+ | 'syncKeys'
23
+ >) {
24
+ return {
25
+ environmentsRelations: relations(t.environments, ({ one }) => ({
26
+ project: one(t.projects, { fields: [t.environments.projectId], references: [t.projects.id] }),
27
+ })),
28
+
29
+ secretsRelations: relations(t.secrets, ({ one }) => ({
30
+ project: one(t.projects, { fields: [t.secrets.projectId], references: [t.projects.id] }),
31
+ environment: one(t.environments, { fields: [t.secrets.environmentId], references: [t.environments.id] }),
32
+ })),
33
+
34
+ syncsRelations: relations(t.syncs, ({ one, many }) => ({
35
+ project: one(t.projects, { fields: [t.syncs.projectId], references: [t.projects.id] }),
36
+ environment: one(t.environments, { fields: [t.syncs.environmentId], references: [t.environments.id] }),
37
+ credential: one(t.secrets, { fields: [t.syncs.credentialSecretId], references: [t.secrets.id] }),
38
+ keys: many(t.syncKeys),
39
+ })),
40
+
41
+ syncKeysRelations: relations(t.syncKeys, ({ one }) => ({
42
+ sync: one(t.syncs, { fields: [t.syncKeys.syncId], references: [t.syncs.id] }),
43
+ })),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * `identities.active_subject`: the subject while the account is bound, null
49
+ * once revoked. A unique index on (provider, issuer_hash, active_subject)
50
+ * binds an account to one person at a time. Plain column names, so the
51
+ * expression reads the same on both databases.
52
+ */
53
+ export const ACTIVE_SUBJECT = sql`CASE WHEN revoked_at IS NULL THEN subject END`;
@@ -0,0 +1,13 @@
1
+ import type { Queryable } from './database.ts';
2
+ import { engineOf, type Engine } from './dialect.ts';
3
+
4
+ /**
5
+ * Minimum migration prefix required by this application image, per engine:
6
+ * each tree has its own history, all ending at the same schema. Until the
7
+ * first deployment, each is its baseline alone (see baseline.ts).
8
+ */
9
+ export const REQUIRED_MIGRATIONS: Record<Engine, number> = { postgres: 1, sqlite: 1 };
10
+
11
+ export function requiredMigrations(db: Queryable): number {
12
+ return REQUIRED_MIGRATIONS[engineOf(db)];
13
+ }
@@ -0,0 +1,511 @@
1
+ import { assignableToEnvironment, ROLE_NAMES } from '@coffre/core/access';
2
+ import { sql } from 'drizzle-orm';
3
+ import {
4
+ blob,
5
+ check,
6
+ customType,
7
+ foreignKey,
8
+ index,
9
+ integer,
10
+ primaryKey,
11
+ sqliteTable,
12
+ text,
13
+ unique,
14
+ uniqueIndex,
15
+ type AnySQLiteColumn,
16
+ } from 'drizzle-orm/sqlite-core';
17
+
18
+ import { asPostgres } from './portable.ts';
19
+ import { ACTIVE_SUBJECT, relationsOf } from './relations.ts';
20
+
21
+ /**
22
+ * The schema in schema.ts, for SQLite (through libsql). Same tables,
23
+ * columns, keys and row types (portable.ts checks); only the storage
24
+ * differs:
25
+ *
26
+ * - Ids and strings are `text`, with no default for ids: the application
27
+ * makes every id.
28
+ * - Bytes are `blob`.
29
+ * - Times are integer milliseconds since the epoch.
30
+ * - Patterns are checked with GLOB, which is case-sensitive and needs no
31
+ * extension, rather than a regular expression.
32
+ */
33
+
34
+ const bytes = (name: string) => blob(name, { mode: 'buffer' });
35
+
36
+ /** A 64-bit integer read as a bigint, like Postgres's bigint in 'bigint' mode. */
37
+ const int64 = customType<{ data: bigint; driverData: number | bigint }>({
38
+ dataType: () => 'integer',
39
+ fromDriver: (value) => BigInt(value),
40
+ });
41
+
42
+ const time = (name: string) => integer(name, { mode: 'timestamp_ms' });
43
+ const now = sql`(CAST(unixepoch('subsec') * 1000 AS INTEGER))`;
44
+ const createdAt = () => time('created_at').notNull().default(now);
45
+ const flag = (name: string) => integer(name, { mode: 'boolean' });
46
+
47
+ /** `^[a-z0-9][a-z0-9-]{0,max-1}$`, in GLOB. */
48
+ const isSlug = (column: AnySQLiteColumn, max: number) =>
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
+
51
+ export const projects = sqliteTable(
52
+ 'projects',
53
+ {
54
+ id: text().primaryKey(),
55
+ slug: text().notNull(),
56
+ name: text().notNull(),
57
+ createdAt: createdAt(),
58
+ archivedAt: time('archived_at'),
59
+ },
60
+ (table) => [
61
+ unique('projects_slug_key').on(table.slug),
62
+ check('projects_slug_check', isSlug(table.slug, 63)),
63
+ ],
64
+ );
65
+
66
+ export const environments = sqliteTable(
67
+ 'environments',
68
+ {
69
+ id: text().primaryKey(),
70
+ projectId: text('project_id').notNull(),
71
+ slug: text().notNull(),
72
+ name: text().notNull(),
73
+ createdAt: createdAt(),
74
+ archivedAt: time('archived_at'),
75
+ },
76
+ (table) => [
77
+ unique('environments_project_id_slug_key').on(table.projectId, table.slug),
78
+ unique('environments_project_scoped').on(table.id, table.projectId),
79
+ check('environments_slug_check', isSlug(table.slug, 63)),
80
+ foreignKey({
81
+ name: 'environments_project_id_fkey',
82
+ columns: [table.projectId],
83
+ foreignColumns: [projects.id],
84
+ }).onDelete('restrict'),
85
+ ],
86
+ );
87
+
88
+ export const secrets = sqliteTable(
89
+ 'secrets',
90
+ {
91
+ id: text().primaryKey(),
92
+ projectId: text('project_id').notNull(),
93
+ environmentId: text('environment_id').notNull(),
94
+ key: text().notNull(),
95
+ createdAt: createdAt(),
96
+ updatedAt: time('updated_at').notNull().default(now),
97
+ currentVersionId: text('current_version_id').references((): AnySQLiteColumn => secretVersions.id, {
98
+ onDelete: 'no action',
99
+ }),
100
+ currentVersion: integer('current_version').notNull().default(0),
101
+ archivedAt: time('archived_at'),
102
+ },
103
+ (table) => [
104
+ unique('secrets_project_id_environment_id_key_key').on(table.projectId, table.environmentId, table.key),
105
+ check(
106
+ 'secrets_key_check',
107
+ 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
+ ),
109
+ foreignKey({
110
+ name: 'secrets_project_id_fkey',
111
+ columns: [table.projectId],
112
+ foreignColumns: [projects.id],
113
+ }).onDelete('restrict'),
114
+ foreignKey({
115
+ name: 'secrets_environment_id_fkey',
116
+ columns: [table.environmentId],
117
+ foreignColumns: [environments.id],
118
+ }).onDelete('restrict'),
119
+ foreignKey({
120
+ name: 'secrets_environment_in_project',
121
+ columns: [table.environmentId, table.projectId],
122
+ foreignColumns: [environments.id, environments.projectId],
123
+ }),
124
+ index('secrets_lookup_idx').on(table.projectId, table.environmentId, table.key),
125
+ ],
126
+ );
127
+
128
+ export const secretVersions = sqliteTable(
129
+ 'secret_versions',
130
+ {
131
+ id: text().primaryKey(),
132
+ secretId: text('secret_id').notNull(),
133
+ version: integer().notNull(),
134
+ envelopeVersion: integer('envelope_version').notNull(),
135
+ ciphertext: bytes('ciphertext').notNull(),
136
+ iv: bytes('iv').notNull(),
137
+ authTag: bytes('auth_tag').notNull(),
138
+ wrappedDek: bytes('wrapped_dek').notNull(),
139
+ kekProvider: text('kek_provider').notNull(),
140
+ kekId: text('kek_id').notNull(),
141
+ kekVersion: text('kek_version').notNull(),
142
+ createdAt: createdAt(),
143
+ createdBy: text('created_by').notNull(),
144
+ },
145
+ (table) => [
146
+ unique('secret_versions_secret_id_version_key').on(table.secretId, table.version),
147
+ check('secret_versions_version_check', sql`${table.version} > 0`),
148
+ check('secret_versions_iv_check', sql`octet_length(${table.iv}) = 12`),
149
+ check('secret_versions_auth_tag_check', sql`octet_length(${table.authTag}) = 16`),
150
+ foreignKey({
151
+ name: 'secret_versions_secret_id_fkey',
152
+ columns: [table.secretId],
153
+ foreignColumns: [secrets.id],
154
+ }).onDelete('restrict'),
155
+ index('secret_versions_secret_idx').on(table.secretId, table.version),
156
+ ],
157
+ );
158
+
159
+ const ROLES = sql.raw(ROLE_NAMES.map((role) => `'${role}'`).join(', '));
160
+ const ENVIRONMENT_ROLES = sql.raw(ROLE_NAMES.filter(assignableToEnvironment).map((role) => `'${role}'`).join(', '));
161
+
162
+ export const vaultMembers = sqliteTable(
163
+ 'vault_members',
164
+ {
165
+ principal: text().primaryKey(),
166
+ status: text().notNull(),
167
+ owner: flag('owner').notNull().default(false),
168
+ generation: integer().notNull().default(0),
169
+ createdAt: integer('created_at', { mode: 'number' }).notNull(),
170
+ createdBy: text('created_by').notNull(),
171
+ statusChangedAt: integer('status_changed_at', { mode: 'number' }).notNull(),
172
+ statusChangedBy: text('status_changed_by').notNull(),
173
+ accessSeq: int64('access_seq').notNull(),
174
+ mac: bytes('mac').notNull(),
175
+ },
176
+ (table) => [
177
+ check('vault_members_mac_check', sql`octet_length(${table.mac}) = 32`),
178
+ // `^(user|token|sync):[^[:space:]:][^[:space:]]*$`, without regular expressions.
179
+ check(
180
+ 'vault_members_principal_check',
181
+ sql`(${table.principal} GLOB 'user:?*' OR ${table.principal} GLOB 'token:?*' OR ${table.principal} GLOB 'sync:?*')
182
+ AND substr(${table.principal}, instr(${table.principal}, ':') + 1, 1) <> ':'
183
+ AND instr(${table.principal}, ' ') = 0 AND instr(${table.principal}, char(9)) = 0
184
+ AND instr(${table.principal}, char(10)) = 0 AND instr(${table.principal}, char(13)) = 0`,
185
+ ),
186
+ check('vault_members_user_lowercase', sql`${table.principal} NOT LIKE 'user:%' OR ${table.principal} = lower(${table.principal})`),
187
+ check('vault_members_status_check', sql`${table.status} IN ('active', 'removed')`),
188
+ check('vault_members_owner_check', sql`NOT ${table.owner} OR (${table.status} = 'active' AND ${table.principal} LIKE 'user:%')`),
189
+ check('vault_members_generation_check', sql`${table.generation} >= 0`),
190
+ ],
191
+ );
192
+
193
+ export const vaultGrants = sqliteTable(
194
+ 'vault_grants',
195
+ {
196
+ principal: text().notNull(),
197
+ projectId: text('project_id'),
198
+ environmentId: text('environment_id'),
199
+ role: text().notNull(),
200
+ expiresAt: integer('expires_at', { mode: 'number' }),
201
+ grantedAt: integer('granted_at', { mode: 'number' }).notNull(),
202
+ grantedBy: text('granted_by').notNull(),
203
+ },
204
+ (table) => [
205
+ check('vault_grants_one_place', sql`(${table.projectId} IS NULL) <> (${table.environmentId} IS NULL)`),
206
+ 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})`),
208
+ unique('vault_grants_on_project').on(table.principal, table.projectId),
209
+ unique('vault_grants_on_environment').on(table.principal, table.environmentId),
210
+ foreignKey({
211
+ name: 'vault_grants_principal_fkey',
212
+ columns: [table.principal],
213
+ foreignColumns: [vaultMembers.principal],
214
+ }).onDelete('restrict'),
215
+ foreignKey({
216
+ name: 'vault_grants_project_id_fkey',
217
+ columns: [table.projectId],
218
+ foreignColumns: [projects.id],
219
+ }).onDelete('restrict'),
220
+ foreignKey({
221
+ name: 'vault_grants_environment_id_fkey',
222
+ columns: [table.environmentId],
223
+ foreignColumns: [environments.id],
224
+ }).onDelete('restrict'),
225
+ ],
226
+ );
227
+
228
+ export const auditLog = sqliteTable(
229
+ 'audit_log',
230
+ {
231
+ seq: int64('seq').primaryKey(),
232
+ // Which component wrote the entry, under which of its keys; see
233
+ // @coffre/core/audit for what its MAC and hash cover.
234
+ author: text().notNull(),
235
+ keyId: text('key_id').notNull(),
236
+ // Milliseconds since the epoch, from the database's clock, read after the
237
+ // append took the head's lock.
238
+ occurredAt: integer('occurred_at', { mode: 'number' }).notNull(),
239
+ // Who acted: `user:<email>`, `token:<id>`, `sync:<id>` or `system:<name>`.
240
+ actor: text().notNull(),
241
+ action: text().notNull(),
242
+ decision: text().notNull(),
243
+ code: text(),
244
+ // The member an access change is about.
245
+ subjectPrincipal: text('subject_principal'),
246
+ projectId: text('project_id'),
247
+ environmentId: text('environment_id'),
248
+ secretId: text('secret_id'),
249
+ secretVersionId: text('secret_version_id'),
250
+ // One id for everything one action did: a reveal's keys, a write's versions.
251
+ operationId: text('operation_id'),
252
+ requestId: text('request_id'),
253
+ sourceIp: text('source_ip'),
254
+ // An earlier entry this one follows from, such as the wrap behind a write.
255
+ relatedSeq: int64('related_seq'),
256
+ metadata: text().notNull().default('{}'),
257
+ prevHash: bytes('prev_hash').notNull(),
258
+ mac: bytes('mac').notNull(),
259
+ hash: bytes('hash').notNull(),
260
+ },
261
+ (table) => [
262
+ check('audit_log_author_check', sql`${table.author} IN ('app', 'vault')`),
263
+ check(
264
+ '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:?*'`,
266
+ ),
267
+ check('audit_log_decision_check', sql`${table.decision} IN ('allow', 'deny')`),
268
+ check('audit_log_metadata_check', sql`json_valid(${table.metadata})`),
269
+ check('audit_log_prev_hash_check', sql`octet_length(${table.prevHash}) = 32`),
270
+ check('audit_log_mac_check', sql`octet_length(${table.mac}) = 32`),
271
+ check('audit_log_hash_check', sql`octet_length(${table.hash}) = 32`),
272
+ foreignKey({
273
+ name: 'audit_log_project_id_fkey',
274
+ columns: [table.projectId],
275
+ foreignColumns: [projects.id],
276
+ }).onDelete('restrict'),
277
+ foreignKey({
278
+ name: 'audit_log_environment_id_fkey',
279
+ columns: [table.environmentId],
280
+ foreignColumns: [environments.id],
281
+ }).onDelete('restrict'),
282
+ foreignKey({
283
+ name: 'audit_log_secret_id_fkey',
284
+ columns: [table.secretId],
285
+ foreignColumns: [secrets.id],
286
+ }).onDelete('restrict'),
287
+ foreignKey({
288
+ name: 'audit_log_secret_version_id_fkey',
289
+ columns: [table.secretVersionId],
290
+ foreignColumns: [secretVersions.id],
291
+ }).onDelete('restrict'),
292
+ foreignKey({
293
+ name: 'audit_log_related_seq_fkey',
294
+ columns: [table.relatedSeq],
295
+ foreignColumns: [table.seq],
296
+ }).onDelete('restrict'),
297
+ // Pages read backwards by seq, within a place or an actor.
298
+ index('audit_log_project_idx').on(table.projectId, table.seq),
299
+ index('audit_log_environment_idx').on(table.environmentId, table.seq),
300
+ index('audit_log_secret_idx').on(table.secretId, table.seq),
301
+ index('audit_log_actor_idx').on(table.actor, table.seq),
302
+ index('audit_log_operation_idx').on(table.operationId, table.seq),
303
+ index('audit_log_action_idx').on(table.author, table.action, table.seq),
304
+ index('audit_log_releases_idx').on(table.author, table.actor, table.action, table.decision, table.occurredAt),
305
+ index('audit_log_subject_idx').on(table.author, table.subjectPrincipal, table.seq),
306
+ ],
307
+ );
308
+
309
+ export const auditChainHead = sqliteTable(
310
+ 'audit_chain_head',
311
+ {
312
+ onlyRow: flag('only_row').primaryKey().default(true),
313
+ nextSeq: int64('next_seq').notNull().default(sql`0`),
314
+ headHash: bytes('head_hash').notNull(),
315
+ },
316
+ (table) => [
317
+ check('audit_chain_head_only_row_check', sql`${table.onlyRow}`),
318
+ check('audit_chain_head_next_seq_check', sql`${table.nextSeq} >= 0`),
319
+ check('audit_chain_head_head_hash_check', sql`octet_length(${table.headHash}) = 32`),
320
+ ],
321
+ );
322
+
323
+ export const identities = sqliteTable(
324
+ 'identities',
325
+ {
326
+ id: text().primaryKey(),
327
+ provider: text().notNull(),
328
+ authMac: bytes('auth_mac').notNull(),
329
+ subject: text().notNull(),
330
+ issuerHash: text('issuer_hash').notNull(),
331
+ generation: integer().notNull(),
332
+ // The member it signs in as, `user:<email>`, under the generation it was bound in.
333
+ principal: text().notNull(),
334
+ email: text(),
335
+ createdAt: createdAt(),
336
+ createdBy: text('created_by').notNull(),
337
+ lastSignInAt: time('last_sign_in_at'),
338
+ revokedAt: time('revoked_at'),
339
+ revokedBy: text('revoked_by'),
340
+ activeSubject: text('active_subject').generatedAlwaysAs(ACTIVE_SUBJECT, { mode: 'stored' }),
341
+ },
342
+ (table) => [
343
+ check('identities_principal_check', sql`${table.principal} LIKE 'user:%'`),
344
+ check('identities_provider_check', isSlug(table.provider, 32)),
345
+ check('identities_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
346
+ unique('identities_member_generation_key').on(table.id, table.principal, table.generation),
347
+ foreignKey({
348
+ name: 'identities_principal_fkey',
349
+ columns: [table.principal],
350
+ foreignColumns: [vaultMembers.principal],
351
+ }).onDelete('restrict'),
352
+ uniqueIndex('identities_active_subject').on(table.provider, table.issuerHash, table.activeSubject),
353
+ index('identities_principal_idx').on(table.principal, table.generation),
354
+ ],
355
+ );
356
+
357
+ export const credentials = sqliteTable(
358
+ 'credentials',
359
+ {
360
+ id: text().primaryKey(),
361
+ kind: text().notNull(),
362
+ authMac: bytes('auth_mac').notNull(),
363
+ tokenHash: bytes('token_hash').notNull(),
364
+ tokenHint: text('token_hint').notNull(),
365
+ generation: integer().notNull(),
366
+ // The member it acts as, `user:<email>` or `token:<id>`, under the generation it was issued in.
367
+ principal: text().notNull(),
368
+ identityId: text('identity_id'),
369
+ label: text(),
370
+ createdAt: createdAt(),
371
+ createdBy: text('created_by').notNull(),
372
+ expiresAt: time('expires_at').notNull(),
373
+ lastUsedAt: time('last_used_at'),
374
+ lastUsedIp: text('last_used_ip'),
375
+ revokedAt: time('revoked_at'),
376
+ revokedBy: text('revoked_by'),
377
+ },
378
+ (table) => [
379
+ unique('credentials_token_hash_key').on(table.tokenHash),
380
+ check('credentials_kind_check', sql`${table.kind} IN ('browser', 'cli', 'service')`),
381
+ check('credentials_principal_check', sql`${table.principal} LIKE 'user:%' OR ${table.principal} LIKE 'token:%'`),
382
+ check('credentials_kind_matches_principal', sql`(${table.kind} = 'service') = (${table.principal} LIKE 'token:%')`),
383
+ check('credentials_token_hash_check', sql`octet_length(${table.tokenHash}) = 32`),
384
+ check('credentials_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
385
+ foreignKey({
386
+ name: 'credentials_principal_fkey',
387
+ columns: [table.principal],
388
+ foreignColumns: [vaultMembers.principal],
389
+ }).onDelete('restrict'),
390
+ // A session's account is its own member's, bound in the same generation.
391
+ foreignKey({
392
+ name: 'credentials_identity_id_fkey',
393
+ columns: [table.identityId, table.principal, table.generation],
394
+ foreignColumns: [identities.id, identities.principal, identities.generation],
395
+ }).onDelete('restrict'),
396
+ index('credentials_principal_idx').on(table.principal, table.generation),
397
+ ],
398
+ );
399
+
400
+ export const deviceAuthorizations = sqliteTable(
401
+ 'device_authorizations',
402
+ {
403
+ id: text().primaryKey(),
404
+ deviceCodeHash: bytes('device_code_hash').notNull(),
405
+ authMac: bytes('auth_mac').notNull(),
406
+ userCode: text('user_code').notNull(),
407
+ clientLabel: text('client_label'),
408
+ clientIp: text('client_ip'),
409
+ createdAt: createdAt(),
410
+ expiresAt: time('expires_at').notNull(),
411
+ decidedAt: time('decided_at'),
412
+ decision: text(),
413
+ generation: integer().notNull().default(0),
414
+ // Who approved it, `user:<email>`, once approved.
415
+ principal: text(),
416
+ consumedAt: time('consumed_at'),
417
+ },
418
+ (table) => [
419
+ unique('device_authorizations_device_code_hash_key').on(table.deviceCodeHash),
420
+ unique('device_authorizations_user_code_key').on(table.userCode),
421
+ check('device_authorizations_auth_mac_check', sql`octet_length(${table.authMac}) = 32`),
422
+ check(
423
+ 'device_authorizations_decision_check',
424
+ sql`${table.decision} IS NULL OR ${table.decision} IN ('approved', 'denied')`,
425
+ ),
426
+ check(
427
+ 'device_authorizations_approval_names_principal',
428
+ sql`(${table.decision} IS NULL AND ${table.decidedAt} IS NULL AND ${table.principal} IS NULL AND ${table.generation} = 0 AND ${table.consumedAt} IS NULL)
429
+ OR (${table.decision} IS NOT NULL AND ${table.decidedAt} IS NOT NULL AND (
430
+ (${table.decision} = 'denied' AND ${table.principal} IS NULL AND ${table.generation} = 0 AND ${table.consumedAt} IS NULL)
431
+ OR (${table.decision} = 'approved' AND ${table.principal} IS NOT NULL AND ${table.principal} LIKE 'user:%')
432
+ ))`,
433
+ ),
434
+ foreignKey({
435
+ name: 'device_authorizations_principal_fkey',
436
+ columns: [table.principal],
437
+ foreignColumns: [vaultMembers.principal],
438
+ }).onDelete('restrict'),
439
+ ],
440
+ );
441
+
442
+ export const syncs = sqliteTable(
443
+ 'syncs',
444
+ {
445
+ id: text().primaryKey(),
446
+ projectId: text('project_id').notNull(),
447
+ environmentId: text('environment_id').notNull(),
448
+ provider: text().notNull(),
449
+ config: text().notNull(),
450
+ credentialSecretId: text('credential_secret_id').notNull(),
451
+ createdAt: createdAt(),
452
+ createdBy: text('created_by').notNull(),
453
+ pausedAt: time('paused_at'),
454
+ archivedAt: time('archived_at'),
455
+ leaseUntil: time('lease_until'),
456
+ lastRunAt: time('last_run_at'),
457
+ lastStatus: text('last_status'),
458
+ lastError: text('last_error'),
459
+ },
460
+ (table) => [
461
+ check('syncs_config_check', sql`json_valid(${table.config})`),
462
+ check(
463
+ 'syncs_last_status_check',
464
+ sql`${table.lastStatus} IS NULL OR ${table.lastStatus} IN ('ok', 'partial', 'failed')`,
465
+ ),
466
+ foreignKey({
467
+ name: 'syncs_environment_in_project',
468
+ columns: [table.environmentId, table.projectId],
469
+ foreignColumns: [environments.id, environments.projectId],
470
+ }).onDelete('restrict'),
471
+ foreignKey({
472
+ name: 'syncs_credential_secret_id_fkey',
473
+ columns: [table.credentialSecretId],
474
+ foreignColumns: [secrets.id],
475
+ }).onDelete('restrict'),
476
+ index('syncs_environment_idx').on(table.environmentId),
477
+ ],
478
+ );
479
+
480
+ export const syncKeys = sqliteTable(
481
+ 'sync_keys',
482
+ {
483
+ syncId: text('sync_id').notNull(),
484
+ key: text().notNull(),
485
+ secretVersionId: text('secret_version_id'),
486
+ pushedAt: time('pushed_at').notNull().default(now),
487
+ removedAt: time('removed_at'),
488
+ },
489
+ (table) => [
490
+ primaryKey({ name: 'sync_keys_pkey', columns: [table.syncId, table.key] }),
491
+ foreignKey({
492
+ name: 'sync_keys_sync_id_fkey',
493
+ columns: [table.syncId],
494
+ foreignColumns: [syncs.id],
495
+ }).onDelete('restrict'),
496
+ foreignKey({
497
+ name: 'sync_keys_secret_version_id_fkey',
498
+ columns: [table.secretVersionId],
499
+ foreignColumns: [secretVersions.id],
500
+ }).onDelete('restrict'),
501
+ ],
502
+ );
503
+
504
+ export const {
505
+ environmentsRelations,
506
+ secretsRelations,
507
+ syncsRelations,
508
+ syncKeysRelations,
509
+ } = relationsOf(
510
+ asPostgres({ projects, environments, secrets, secretVersions, syncs, syncKeys }),
511
+ );