@fabrika/platform-node 0.3.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/platform-node",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,6 +25,11 @@
25
25
  "bun": "./src/postgres-migrations.ts",
26
26
  "types": "./src/postgres-migrations.ts",
27
27
  "default": "./src/postgres-migrations.ts"
28
+ },
29
+ "./sql": {
30
+ "bun": "./src/sql.ts",
31
+ "types": "./src/sql.ts",
32
+ "default": "./src/sql.ts"
28
33
  }
29
34
  },
30
35
  "scripts": {
@@ -32,6 +37,6 @@
32
37
  "test": "bun test"
33
38
  },
34
39
  "dependencies": {
35
- "@fabrika/platform": "^0.3.2"
40
+ "@fabrika/platform": "^0.4.0"
36
41
  }
37
42
  }
@@ -0,0 +1,117 @@
1
+ import type { SqlDatabase } from '@fabrika/platform'
2
+ import { PostgresMigrationError } from './postgres-migration-error'
3
+
4
+ /** Postgres' `lock_not_available`, which `lock_timeout` raises. */
5
+ const LOCK_NOT_AVAILABLE = '55P03'
6
+
7
+ const sqlState = (error: unknown): unknown => typeof error === 'object' && error !== null ? Reflect.get(error, 'errno') : undefined
8
+
9
+ /**
10
+ * Takes the migration's session-level advisory lock. Any role may take any advisory lock, the application's own
11
+ * runtime included, so an application's wait is bounded by `timeoutMs`; a service plan waits as long as it takes.
12
+ */
13
+ export async function acquireMigrationLock(db: SqlDatabase, lockKey: number, timeoutMs: number | undefined, label: string): Promise<void> {
14
+ const lock = db.prepare(`SELECT pg_catalog.pg_advisory_lock(${lockKey})`)
15
+ if (timeoutMs === undefined) {
16
+ await lock.run()
17
+ return
18
+ }
19
+ await db.prepare(`SET lock_timeout = ${timeoutMs}`).run()
20
+ try {
21
+ await lock.run()
22
+ } catch (error) {
23
+ if (sqlState(error) === LOCK_NOT_AVAILABLE) {
24
+ throw new PostgresMigrationError(
25
+ `the migration lock of ${label} was not free within ${timeoutMs} ms: another migration of the application, or a session that holds its advisory lock, still runs`,
26
+ )
27
+ }
28
+ throw error
29
+ } finally {
30
+ await db.prepare('RESET lock_timeout').run()
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Clears PUBLIC from the default privileges of the session's role (the migration role), globally and in its schema,
36
+ * before the plan creates anything. A default the provisioning credential or a superuser set would otherwise hand
37
+ * every table a later migration creates to the runtime role through PUBLIC.
38
+ */
39
+ export async function normalizeDefaultPrivileges(db: SqlDatabase, schema: string): Promise<void> {
40
+ const kinds = ['TABLES', 'SEQUENCES', 'FUNCTIONS', 'TYPES']
41
+ await db.batch([
42
+ ...kinds.map((kind) => db.prepare(`ALTER DEFAULT PRIVILEGES REVOKE ALL ON ${kind} FROM PUBLIC`)),
43
+ ...kinds.map((kind) => db.prepare(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${schema} REVOKE ALL ON ${kind} FROM PUBLIC`)),
44
+ ])
45
+ }
46
+
47
+ /**
48
+ * Resets the schema's privileges to what the runtime role needs: PUBLIC and the runtime role lose everything on the
49
+ * schema and its objects, then the runtime role gets USAGE, DML on every table but the ledger, EXECUTE on the schema's
50
+ * routines and USAGE on its types. One transaction, so a live runtime never sees a gap. Then the effective privileges
51
+ * are verified, PUBLIC included, and anything beyond that stops the deploy.
52
+ */
53
+ export async function grantRuntimeRole(db: SqlDatabase, schema: string, ledgerTable: string, role: string): Promise<void> {
54
+ try {
55
+ await db.batch([
56
+ db.prepare('SET LOCAL search_path TO pg_catalog, pg_temp'),
57
+ db.prepare(`REVOKE ALL ON SCHEMA ${schema} FROM PUBLIC, ${role}`),
58
+ db.prepare(`REVOKE ALL ON ALL TABLES IN SCHEMA ${schema} FROM PUBLIC, ${role}`),
59
+ db.prepare(`REVOKE ALL ON ALL SEQUENCES IN SCHEMA ${schema} FROM PUBLIC, ${role}`),
60
+ db.prepare(`REVOKE ALL ON ALL ROUTINES IN SCHEMA ${schema} FROM PUBLIC, ${role}`),
61
+ db.prepare(`DO $types$
62
+ DECLARE t pg_catalog.regtype;
63
+ BEGIN
64
+ FOR t IN SELECT y.oid::pg_catalog.regtype FROM pg_catalog.pg_type y LEFT JOIN pg_catalog.pg_class c ON c.oid = y.typrelid
65
+ WHERE y.typnamespace = '${schema}'::pg_catalog.regnamespace AND (y.typtype IN ('e', 'd', 'r') OR c.relkind = 'c')
66
+ LOOP
67
+ EXECUTE pg_catalog.format('REVOKE ALL ON TYPE %s FROM PUBLIC, ${role}', t);
68
+ EXECUTE pg_catalog.format('GRANT USAGE ON TYPE %s TO ${role}', t);
69
+ END LOOP;
70
+ END $types$`),
71
+ db.prepare(`GRANT USAGE ON SCHEMA ${schema} TO ${role}`),
72
+ db.prepare(`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${schema} TO ${role}`),
73
+ db.prepare(`REVOKE ALL ON TABLE ${ledgerTable} FROM ${role}`),
74
+ db.prepare(`GRANT EXECUTE ON ALL ROUTINES IN SCHEMA ${schema} TO ${role}`),
75
+ ])
76
+ } catch (error) {
77
+ throw new PostgresMigrationError(`granting the runtime role ${role} its privileges in ${schema} failed`, { cause: error })
78
+ }
79
+ await verifyRuntimeRole(db, schema, ledgerTable, role)
80
+ }
81
+
82
+ interface Problem {
83
+ readonly problem: string
84
+ }
85
+
86
+ /** The runtime role's effective privileges in its schema, PUBLIC's included, and the migration role's default privileges. */
87
+ async function verifyRuntimeRole(db: SqlDatabase, schema: string, ledgerTable: string, role: string): Promise<void> {
88
+ const { results } = await db.prepare(`
89
+ WITH runtime AS (SELECT oid, rolname FROM pg_catalog.pg_roles WHERE rolname OPERATOR(pg_catalog.=) ?::pg_catalog.name),
90
+ ledger AS (SELECT pg_catalog.to_regclass(?::pg_catalog.text) AS oid),
91
+ table_privileges AS (
92
+ SELECT pg_catalog.unnest(CASE WHEN pg_catalog.current_setting('server_version_num')::pg_catalog.int4 OPERATOR(pg_catalog.>=) 170000
93
+ THEN ARRAY['TRUNCATE', 'REFERENCES', 'TRIGGER', 'MAINTAIN'] ELSE ARRAY['TRUNCATE', 'REFERENCES', 'TRIGGER'] END) AS privilege)
94
+ SELECT pg_catalog.format('%s may create objects in schema %s', runtime.rolname, ?::pg_catalog.text) AS problem FROM runtime
95
+ WHERE pg_catalog.has_schema_privilege(runtime.oid, ?::pg_catalog.text, 'CREATE')
96
+ UNION ALL
97
+ SELECT pg_catalog.format('%s holds %s on %s', runtime.rolname, p.privilege, c.oid::pg_catalog.regclass) FROM runtime, table_privileges p, pg_catalog.pg_class c
98
+ WHERE c.relnamespace OPERATOR(pg_catalog.=) ?::pg_catalog.regnamespace AND c.relkind OPERATOR(pg_catalog.=) ANY (ARRAY['r', 'p', 'v', 'm', 'f']::"char"[])
99
+ AND pg_catalog.has_table_privilege(runtime.oid, c.oid, p.privilege)
100
+ UNION ALL
101
+ SELECT pg_catalog.format('%s may %s the migration ledger', runtime.rolname, p.privilege) FROM runtime, ledger, pg_catalog.unnest(ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE']) AS p(privilege)
102
+ WHERE pg_catalog.has_table_privilege(runtime.oid, ledger.oid, p.privilege)
103
+ OR (p.privilege OPERATOR(pg_catalog.<>) 'DELETE' AND pg_catalog.has_any_column_privilege(runtime.oid, ledger.oid, p.privilege))
104
+ UNION ALL
105
+ SELECT pg_catalog.format('a default privilege of %s grants %s on every new object of kind %s to %s', pg_catalog.pg_get_userbyid(d.defaclrole),
106
+ a.privilege_type, d.defaclobjtype, CASE WHEN a.grantee OPERATOR(pg_catalog.=) 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(a.grantee) END)
107
+ FROM pg_catalog.pg_default_acl d, pg_catalog.aclexplode(d.defaclacl) a
108
+ WHERE d.defaclrole OPERATOR(pg_catalog.=) (SELECT oid FROM pg_catalog.pg_roles WHERE rolname OPERATOR(pg_catalog.=) current_user)
109
+ AND a.grantee OPERATOR(pg_catalog.<>) d.defaclrole`).bind(role, ledgerTable, schema, schema, schema).all<Problem>()
110
+ if (results.length > 0) {
111
+ throw new PostgresMigrationError(
112
+ `${
113
+ results.map((row) => row.problem).join('; ')
114
+ }; the deploy stops, because the runtime role ${role} has DML on its tables and nothing else (ADR-0017)`,
115
+ )
116
+ }
117
+ }
@@ -0,0 +1,152 @@
1
+ import type { SqlDatabase } from '@fabrika/platform'
2
+ import { createHash } from 'node:crypto'
3
+ import { existsSync } from 'node:fs'
4
+ import { type ApplicationDatabaseRoles, dropPostgresRoles, postgresRoleUrl, provisionPostgresRoles } from './application-postgres-roles'
5
+ import { applicationDatabaseSchema, validateApplicationId } from './application-schema'
6
+ import { classifyMigrations, type DatabaseObject } from './migration-classify'
7
+ import {
8
+ applyPostgresMigrations,
9
+ definePostgresMigrationPlan,
10
+ type PostgresMigration,
11
+ PostgresMigrationError,
12
+ type PostgresMigrationPlan,
13
+ readPostgresMigrationBundle,
14
+ } from './postgres-migrations'
15
+ import { PostgresDatabase } from './sql-postgres'
16
+
17
+ /** The file-name contract the checker enforces (`db/migrations/`, `@fabrika/check`); timestamps keep worktrees apart. */
18
+ const APPLICATION_MIGRATION_NAME = /^\d{14}_[a-z0-9]+(?:_[a-z0-9]+)*\.sql$/
19
+
20
+ /** Migration identity of every application (ADR-0017): bundle `app`, ledger `app_<id>.fabrika_migrations`. */
21
+ export const APPLICATION_MIGRATION_BUNDLE = 'app'
22
+ export const APPLICATION_MIGRATION_LEDGER = 'fabrika_migrations'
23
+ /** Fails a migration that waits this long for a lock, so a blocked `ALTER` does not queue the live version's queries. */
24
+ export const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 5_000
25
+ /** Bounds a statement such as a backfill or a non-concurrent index build, which holds its lock while it runs. */
26
+ export const DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 60_000
27
+ /** How long `migrate` waits for the application's migration lock: another container's migrations, or a stray holder. */
28
+ export const DEFAULT_MIGRATION_ADVISORY_LOCK_TIMEOUT_MS = 300_000
29
+
30
+ export interface PostgresApplicationMigrationsConfig {
31
+ readonly appId: string
32
+ /** The application's `db/migrations/` directory. */
33
+ readonly directory: string
34
+ readonly lockTimeoutMs?: number
35
+ readonly statementTimeoutMs?: number
36
+ readonly advisoryLockTimeoutMs?: number
37
+ /**
38
+ * The framework's own tables (`fabrika_*`, `@fabrika/db/migrations`), applied as their own bundle before the
39
+ * application's, in the same ledger and under the same checksum rule.
40
+ */
41
+ readonly framework?: { readonly bundle: string; readonly migrations: readonly PostgresMigration[] }
42
+ }
43
+
44
+ export interface PostgresApplicationMigrations {
45
+ /** `app_<id>`: the application's schema, which the data layer sets as its `search_path`. */
46
+ readonly schema: string
47
+ readonly roles: ApplicationDatabaseRoles
48
+ read(): PostgresMigration[]
49
+ plan(migrations?: readonly PostgresMigration[]): PostgresMigrationPlan
50
+ /**
51
+ * The manifest of the framework's and the application's migrations: the database objects they declare, which the
52
+ * catalog check admits at every start (`fabrika db codegen`) as the runner does before each commit.
53
+ */
54
+ objects(migrations?: readonly PostgresMigration[]): DatabaseObject[]
55
+ /** Applies the plan over `db` as whatever role it connects as, and grants no runtime role: the runner's own tests. */
56
+ apply(db: SqlDatabase, migrations?: readonly PostgresMigration[]): Promise<string[]>
57
+ /**
58
+ * What `fabrika db migrate` runs. With the provisioning credential it creates or checks the role family and the
59
+ * schema; then it applies the plan as the migration role and grants the runtime role. No migration runs as the
60
+ * provisioning credential, and a credential that cannot create roles is refused.
61
+ */
62
+ migrate(provisioningUrl: string, migrations?: readonly PostgresMigration[]): Promise<string[]>
63
+ }
64
+
65
+ /** The first 48 bits of SHA-256(`fabrika-db:<appId>`), plus one: applications sharing a database do not serialize each other. */
66
+ export function applicationMigrationLockKey(appId: string): number {
67
+ validateApplicationId(appId)
68
+ return createHash('sha256').update(`fabrika-db:${appId}`, 'utf8').digest().readUIntBE(0, 6) + 1
69
+ }
70
+
71
+ /** The application's schema, its migration role (named like the schema) and its runtime role `run_<id>`. */
72
+ export function applicationDatabaseRoles(appId: string): ApplicationDatabaseRoles {
73
+ const schema = applicationDatabaseSchema(appId)
74
+ return { schema, migration: schema, runtime: `run_${schema.slice('app_'.length)}` }
75
+ }
76
+
77
+ /** The runtime role's connection URL, derived from the provisioning credential the platform hands the application. */
78
+ export function applicationRuntimeUrl(provisioningUrl: string, appId: string): string {
79
+ return postgresRoleUrl(provisioningUrl, applicationDatabaseRoles(appId).runtime)
80
+ }
81
+
82
+ /** Drops an application's schema and both of its roles, as the provisioning credential: for throwaway test databases. */
83
+ export async function dropApplicationDatabase(provisioningUrl: string, appId: string): Promise<void> {
84
+ const admin = PostgresDatabase.connect(provisioningUrl, { max: 1 })
85
+ try {
86
+ await dropPostgresRoles(admin, applicationDatabaseRoles(appId))
87
+ } finally {
88
+ await admin.close()
89
+ }
90
+ }
91
+
92
+ /** An application's migration wrapper: its schema, ledger, lock and timeouts are derived, never configured. */
93
+ export function definePostgresApplicationMigrations(config: PostgresApplicationMigrationsConfig): PostgresApplicationMigrations {
94
+ const roles = applicationDatabaseRoles(config.appId)
95
+ const { schema } = roles
96
+ const lockKey = applicationMigrationLockKey(config.appId)
97
+ const advisoryLockTimeoutMs = config.advisoryLockTimeoutMs ?? DEFAULT_MIGRATION_ADVISORY_LOCK_TIMEOUT_MS
98
+ const read = (): PostgresMigration[] => {
99
+ if (!existsSync(config.directory)) {
100
+ throw new PostgresMigrationError(`No migrations directory at ${config.directory}; create the first migration with fabrika db new <slug>`)
101
+ }
102
+ const { migrations } = readPostgresMigrationBundle({ name: APPLICATION_MIGRATION_BUNDLE, directory: config.directory })
103
+ return [...migrations]
104
+ }
105
+ const planWith = (migrations: readonly PostgresMigration[], runtimeRole: string | undefined): PostgresMigrationPlan => {
106
+ for (const migration of migrations) {
107
+ if (!APPLICATION_MIGRATION_NAME.test(migration.name)) {
108
+ throw new PostgresMigrationError(`Application migration ${migration.name} is not named YYYYMMDDHHMMSS_slug.sql`)
109
+ }
110
+ }
111
+ return definePostgresMigrationPlan({
112
+ ledgerTable: `${schema}.${APPLICATION_MIGRATION_LEDGER}`,
113
+ lockKey,
114
+ bundles: [
115
+ ...(config.framework === undefined ? [] : [{ name: config.framework.bundle, migrations: config.framework.migrations }]),
116
+ { name: APPLICATION_MIGRATION_BUNDLE, migrations },
117
+ ],
118
+ application: {
119
+ schema,
120
+ lockTimeoutMs: config.lockTimeoutMs ?? DEFAULT_MIGRATION_LOCK_TIMEOUT_MS,
121
+ statementTimeoutMs: config.statementTimeoutMs ?? DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS,
122
+ ...(config.framework === undefined ? {} : { frameworkBundle: config.framework.bundle }),
123
+ ...(runtimeRole === undefined ? {} : { runtimeRole, advisoryLockTimeoutMs }),
124
+ },
125
+ })
126
+ }
127
+ const plan = (migrations: readonly PostgresMigration[] = read()): PostgresMigrationPlan => planWith(migrations, undefined)
128
+ const objects = (migrations: readonly PostgresMigration[] = read()): DatabaseObject[] => {
129
+ const framework = (config.framework?.migrations ?? []).map((migration) => ({ ...migration, framework: true }))
130
+ return [...classifyMigrations([...framework, ...migrations], schema).objects]
131
+ }
132
+ const apply = async (db: SqlDatabase, migrations: readonly PostgresMigration[] = read()): Promise<string[]> =>
133
+ applyPostgresMigrations(db, plan(migrations))
134
+ const migrate = async (provisioningUrl: string, migrations: readonly PostgresMigration[] = read()): Promise<string[]> => {
135
+ const planned = planWith(migrations, roles.runtime)
136
+ const migrationUrl = postgresRoleUrl(provisioningUrl, roles.migration)
137
+ // One connection each: the advisory lock is session state.
138
+ const admin = PostgresDatabase.connect(provisioningUrl, { max: 1 })
139
+ try {
140
+ await provisionPostgresRoles(admin, provisioningUrl, roles, { key: lockKey, timeoutMs: advisoryLockTimeoutMs })
141
+ } finally {
142
+ await admin.close()
143
+ }
144
+ const owner = PostgresDatabase.connect(migrationUrl, { max: 1 })
145
+ try {
146
+ return await applyPostgresMigrations(owner, planned)
147
+ } finally {
148
+ await owner.close()
149
+ }
150
+ }
151
+ return { schema, roles, read, plan, objects, apply, migrate }
152
+ }
@@ -0,0 +1,239 @@
1
+ import type { SqlDatabase } from '@fabrika/platform'
2
+ import { createHash, createHmac, pbkdf2Sync, randomBytes } from 'node:crypto'
3
+ import { acquireMigrationLock } from './application-postgres-grants'
4
+ import { PostgresMigrationError } from './postgres-migration-error'
5
+
6
+ /** An application's schema and the two roles that reach it (ADR-0017). */
7
+ export interface ApplicationDatabaseRoles {
8
+ /** `app_<id>`. */
9
+ readonly schema: string
10
+ /** Owns the schema and every object in it, so it is the only role that runs DDL there. Named like the schema. */
11
+ readonly migration: string
12
+ /** `run_<id>`: DML on the schema's tables except the migration ledger, and nothing else. */
13
+ readonly runtime: string
14
+ }
15
+
16
+ const ROLE_NAME = /^(?:app|run)_[a-z0-9_]{1,59}$/
17
+ const SCRAM_ITERATIONS = 4096
18
+
19
+ function validateRoles(roles: ApplicationDatabaseRoles): void {
20
+ for (const name of [roles.schema, roles.migration, roles.runtime]) {
21
+ if (!ROLE_NAME.test(name)) throw new PostgresMigrationError(`Invalid application database role or schema: ${name}`)
22
+ }
23
+ }
24
+
25
+ export function provisioningCredential(provisioningUrl: string): { readonly url: URL; readonly password: string } {
26
+ let url: URL
27
+ try {
28
+ url = new URL(provisioningUrl)
29
+ } catch {
30
+ throw new PostgresMigrationError('the provisioning credential is not a connection URL')
31
+ }
32
+ const password = decodeURIComponent(url.password)
33
+ if (password === '') {
34
+ throw new PostgresMigrationError("the provisioning credential has no password; the application's database roles derive theirs from it")
35
+ }
36
+ return { url, password }
37
+ }
38
+
39
+ /** HMAC-SHA-256 of the role's name under the provisioning password: whoever holds that credential derives it, so nothing stores it. */
40
+ function derivedPassword(provisioningPassword: string, role: string): string {
41
+ return createHmac('sha256', provisioningPassword).update(`fabrika-db-role:${role}`, 'utf8').digest('base64url')
42
+ }
43
+
44
+ /** Postgres' SCRAM-SHA-256 verifier (RFC 7677): the password itself never reaches the server, so no statement log holds it. */
45
+ export function scramVerifier(password: string): string {
46
+ const salt = randomBytes(16)
47
+ const salted = pbkdf2Sync(password, salt, SCRAM_ITERATIONS, 32, 'sha256')
48
+ const storedKey = createHash('sha256').update(createHmac('sha256', salted).update('Client Key').digest()).digest()
49
+ const serverKey = createHmac('sha256', salted).update('Server Key').digest()
50
+ return `SCRAM-SHA-256$${SCRAM_ITERATIONS}:${salt.toString('base64')}$${storedKey.toString('base64')}:${serverKey.toString('base64')}`
51
+ }
52
+
53
+ /** The URL `role` connects with: the provisioning URL's host, database and options, the role's name and derived password. */
54
+ export function postgresRoleUrl(provisioningUrl: string, role: string): string {
55
+ const { url, password } = provisioningCredential(provisioningUrl)
56
+ url.username = role
57
+ url.password = derivedPassword(password, role)
58
+ return url.toString()
59
+ }
60
+
61
+ interface ProvisioningRole {
62
+ readonly rolname: string
63
+ readonly rolsuper: boolean
64
+ readonly rolcreaterole: boolean
65
+ }
66
+
67
+ interface RoleReach {
68
+ readonly rolname: string
69
+ readonly attributes: string | null
70
+ readonly memberships: string | null
71
+ readonly creates_schemas: boolean
72
+ readonly creates_in: string | null
73
+ }
74
+
75
+ /**
76
+ * Creates the role family and the schema when absent, resets both roles' passwords to their derived values, and refuses
77
+ * a family that holds more than it should. Runs over one connection of the provisioning credential, under the
78
+ * application's migration lock, so two containers starting together do not race on the same catalog rows.
79
+ */
80
+ export async function provisionPostgresRoles(
81
+ admin: SqlDatabase,
82
+ provisioningUrl: string,
83
+ roles: ApplicationDatabaseRoles,
84
+ lock: { readonly key: number; readonly timeoutMs: number },
85
+ ): Promise<void> {
86
+ validateRoles(roles)
87
+ const { password } = provisioningCredential(provisioningUrl)
88
+ // A dedicated session: nothing an application created can shadow a catalog function here.
89
+ await admin.prepare('SET search_path TO pg_catalog, pg_temp').run()
90
+ await acquireMigrationLock(admin, lock.key, lock.timeoutMs, roles.schema)
91
+ try {
92
+ const self = await admin.prepare('SELECT rolname, rolsuper, rolcreaterole FROM pg_roles WHERE rolname = current_user').first<ProvisioningRole>()
93
+ if (self === null || (!self.rolsuper && !self.rolcreaterole)) {
94
+ throw new PostgresMigrationError(
95
+ `the provisioning credential connects as ${
96
+ self?.rolname ?? 'an unknown role'
97
+ }, which is neither a superuser nor holds CREATEROLE, so it cannot create the application's database roles ${roles.migration} and ${roles.runtime}; nothing runs as the provisioning credential instead`,
98
+ )
99
+ }
100
+ for (const role of [roles.migration, roles.runtime]) {
101
+ const verifier = scramVerifier(derivedPassword(password, role))
102
+ const exists = await admin.prepare('SELECT true AS present FROM pg_roles WHERE rolname = ?').bind(role).first()
103
+ // Defaults are NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; `verifyRoles` holds an existing role to them.
104
+ if (exists === null) {
105
+ await admin.prepare(`CREATE ROLE ${role} LOGIN NOINHERIT PASSWORD '${verifier}'`).run()
106
+ continue
107
+ }
108
+ await admin.prepare(`ALTER ROLE ${role} WITH LOGIN PASSWORD '${verifier}'`).run()
109
+ // A role may set its own session defaults (`ALTER ROLE CURRENT_USER SET …`); each deploy starts it clean.
110
+ await admin.prepare(`ALTER ROLE ${role} RESET ALL`).run()
111
+ }
112
+ // A CREATEROLE role holds only ADMIN on a role it created; creating a schema for it needs SET (Postgres 16).
113
+ if (!self.rolsuper) await admin.prepare(`GRANT ${roles.migration} TO CURRENT_USER`).run()
114
+ const schema = await admin.prepare('SELECT pg_get_userbyid(nspowner) AS owner FROM pg_namespace WHERE nspname = ?').bind(roles.schema).first<{
115
+ owner: string
116
+ }>()
117
+ if (schema === null) {
118
+ await admin.prepare(`CREATE SCHEMA ${roles.schema} AUTHORIZATION ${roles.migration}`).run()
119
+ } else if (schema.owner !== roles.migration) {
120
+ throw new PostgresMigrationError(
121
+ `schema ${roles.schema} is owned by ${schema.owner}, not by its migration role ${roles.migration}: it predates the application's database roles and is not adopted; drop it to start over, or make ${roles.migration} the owner of the schema and of every object in it`,
122
+ )
123
+ }
124
+ await verifyRoles(admin, roles)
125
+ await verifyForeignReach(admin, roles)
126
+ } finally {
127
+ await admin.prepare(`SELECT pg_advisory_unlock(${lock.key})`).run()
128
+ }
129
+ }
130
+
131
+ /** Fails closed on any reach beyond the family's: a widened attribute, a membership, or CREATE outside the migration role's schema. */
132
+ async function verifyRoles(admin: SqlDatabase, roles: ApplicationDatabaseRoles): Promise<void> {
133
+ const { results } = await admin.prepare(`SELECT r.rolname,
134
+ concat_ws(', ',
135
+ CASE WHEN r.rolsuper THEN 'SUPERUSER' END, CASE WHEN r.rolcreaterole THEN 'CREATEROLE' END,
136
+ CASE WHEN r.rolcreatedb THEN 'CREATEDB' END, CASE WHEN r.rolreplication THEN 'REPLICATION' END,
137
+ CASE WHEN r.rolbypassrls THEN 'BYPASSRLS' END) AS attributes,
138
+ (SELECT string_agg(pg_get_userbyid(m.roleid), ', ' ORDER BY pg_get_userbyid(m.roleid)) FROM pg_auth_members m WHERE m.member = r.oid) AS memberships,
139
+ has_database_privilege(r.oid, current_database(), 'CREATE') AS creates_schemas,
140
+ (SELECT string_agg(n.nspname, ', ' ORDER BY n.nspname) FROM pg_namespace n WHERE has_schema_privilege(r.oid, n.oid, 'CREATE')) AS creates_in
141
+ FROM pg_roles r WHERE r.rolname IN (?, ?)`).bind(roles.migration, roles.runtime).all<RoleReach>()
142
+ const problems: string[] = []
143
+ for (const role of results) {
144
+ const subject = `the database role ${role.rolname}`
145
+ if (role.attributes !== null && role.attributes !== '') problems.push(`${subject} holds ${role.attributes}`)
146
+ if (role.memberships !== null) problems.push(`${subject} is a member of ${role.memberships}`)
147
+ if (role.creates_schemas) problems.push(`${subject} may create schemas in the database`)
148
+ const allowed = role.rolname === roles.migration ? roles.schema : null
149
+ if (role.creates_in !== allowed) {
150
+ problems.push(`${subject} may create objects in ${role.creates_in ?? 'no schema'} (allowed: ${allowed ?? 'none'})`)
151
+ }
152
+ }
153
+ if (results.length !== 2) problems.push(`the database roles ${roles.migration} and ${roles.runtime} do not both exist`)
154
+ if (problems.length > 0) {
155
+ throw new PostgresMigrationError(
156
+ `${problems.join('; ')}; the deploy stops until it is undone, because these roles bound what the application's SQL may do (ADR-0017)`,
157
+ )
158
+ }
159
+ }
160
+
161
+ interface ForeignGrant {
162
+ readonly role: string
163
+ readonly object: string
164
+ readonly privileges: string
165
+ }
166
+
167
+ /**
168
+ * Fails closed on any reach of the family into the rest of the database: a privilege either role was granted, or
169
+ * ownership it holds, on a schema, relation, column, routine, type, the database, a large object or a parameter outside
170
+ * its schema; and a SECURITY DEFINER routine either may call, which would run as its owner. PUBLIC's own defaults
171
+ * (CONNECT, TEMP, EXECUTE on built-ins) are not grants to the roles; a large object a role created itself is its own.
172
+ */
173
+ async function verifyForeignReach(admin: SqlDatabase, roles: ApplicationDatabaseRoles): Promise<void> {
174
+ const { results } = await admin.prepare(`
175
+ WITH family AS (SELECT oid, rolname FROM pg_roles WHERE rolname IN (?, ?)),
176
+ own AS (SELECT oid FROM pg_namespace WHERE nspname = ?),
177
+ objects (rank, object, owner, acl) AS (
178
+ SELECT 1, 'schema ' || quote_ident(nspname), nspowner, nspacl FROM pg_namespace WHERE oid NOT IN (SELECT oid FROM own)
179
+ UNION ALL SELECT 2, CASE relkind WHEN 'S' THEN 'sequence ' WHEN 'v' THEN 'view ' WHEN 'm' THEN 'materialized view ' ELSE 'table ' END
180
+ || relnamespace::regnamespace || '.' || quote_ident(relname), relowner, relacl
181
+ FROM pg_class WHERE relnamespace NOT IN (SELECT oid FROM own) AND relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
182
+ UNION ALL SELECT 3, 'column ' || c.relnamespace::regnamespace || '.' || quote_ident(c.relname) || '.' || quote_ident(a.attname), c.relowner, a.attacl
183
+ FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid WHERE a.attacl IS NOT NULL AND c.relnamespace NOT IN (SELECT oid FROM own)
184
+ UNION ALL SELECT 4, 'function ' || oid::regprocedure, proowner, proacl FROM pg_proc WHERE pronamespace NOT IN (SELECT oid FROM own)
185
+ UNION ALL SELECT 5, 'type ' || oid::regtype, typowner, typacl FROM pg_type WHERE typnamespace NOT IN (SELECT oid FROM own)
186
+ UNION ALL SELECT 6, 'database ' || quote_ident(datname), datdba, datacl FROM pg_database WHERE datname = current_database()
187
+ UNION ALL SELECT 7, 'large object ' || oid, lomowner, lomacl FROM pg_largeobject_metadata
188
+ UNION ALL SELECT 8, 'parameter ' || parname, NULL, paracl FROM pg_parameter_acl
189
+ )
190
+ SELECT f.rolname AS role, o.object, string_agg(DISTINCT a.privilege_type, ', ') AS privileges
191
+ FROM objects o CROSS JOIN LATERAL aclexplode(o.acl) a JOIN family f ON f.oid = a.grantee
192
+ WHERE o.owner IS DISTINCT FROM a.grantee
193
+ GROUP BY f.rolname, o.rank, o.object
194
+ UNION ALL
195
+ SELECT f.rolname, o.object, 'OWNER' FROM objects o JOIN family f ON f.oid = o.owner WHERE o.rank NOT IN (6, 7)
196
+ ORDER BY 1, 2`).bind(roles.migration, roles.runtime, roles.schema).all<ForeignGrant>()
197
+ const problems: string[] = []
198
+ for (const role of [roles.migration, roles.runtime]) {
199
+ const held = results.filter((grant) => grant.role === role).map((grant) => `${grant.object} (${grant.privileges})`)
200
+ if (held.length > 0) problems.push(`the database role ${role} holds privileges outside its schema: ${held.join(', ')}`)
201
+ }
202
+ const { results: definers } = await admin.prepare(`
203
+ SELECT p.oid::regprocedure::text AS routine, pg_get_userbyid(p.proowner) AS owner FROM pg_proc p
204
+ WHERE p.prosecdef AND p.pronamespace NOT IN ('pg_catalog'::regnamespace, 'information_schema'::regnamespace)
205
+ AND p.proowner NOT IN (SELECT oid FROM pg_roles WHERE rolname IN (?, ?))
206
+ AND EXISTS (SELECT 1 FROM pg_roles r WHERE r.rolname IN (?, ?)
207
+ AND has_schema_privilege(r.oid, p.pronamespace, 'USAGE') AND has_function_privilege(r.oid, p.oid, 'EXECUTE'))
208
+ ORDER BY 1`).bind(roles.migration, roles.runtime, roles.migration, roles.runtime).all<{ routine: string; owner: string }>()
209
+ for (const definer of definers) {
210
+ problems.push(
211
+ `the SECURITY DEFINER function ${definer.routine} runs as ${definer.owner} and the application's roles may call it; revoke EXECUTE on it from PUBLIC or drop it`,
212
+ )
213
+ }
214
+ if (problems.length > 0) {
215
+ throw new PostgresMigrationError(`${problems.join('; ')}; the deploy stops, because these roles bound what the application's SQL may do (ADR-0017)`)
216
+ }
217
+ }
218
+
219
+ export const quotedIdentifier = (name: string): string => `"${name.replaceAll('"', '""')}"`
220
+
221
+ /**
222
+ * Drops the schema and both roles: for throwaway test databases only. The schema is dropped as its owner, which a
223
+ * CREATEROLE provisioning credential may become (`provisionPostgresRoles` granted it the migration role).
224
+ */
225
+ export async function dropPostgresRoles(admin: SqlDatabase, roles: ApplicationDatabaseRoles): Promise<void> {
226
+ validateRoles(roles)
227
+ const schema = await admin.prepare('SELECT pg_catalog.pg_get_userbyid(nspowner) AS owner FROM pg_catalog.pg_namespace WHERE nspname = ?').bind(
228
+ roles.schema,
229
+ ).first<{ owner: string }>()
230
+ if (schema !== null) {
231
+ await admin.batch([admin.prepare(`SET LOCAL ROLE ${quotedIdentifier(schema.owner)}`), admin.prepare(`DROP SCHEMA ${roles.schema} CASCADE`)])
232
+ }
233
+ // The migration role's default privileges (`normalizeDefaultPrivileges`) are objects of its own that block DROP ROLE.
234
+ const migration = await admin.prepare('SELECT true AS present FROM pg_catalog.pg_roles WHERE rolname = ?').bind(roles.migration).first()
235
+ if (migration !== null) {
236
+ await admin.batch([admin.prepare(`SET LOCAL ROLE ${roles.migration}`), admin.prepare(`DROP OWNED BY ${roles.migration}`)])
237
+ }
238
+ await admin.prepare(`DROP ROLE IF EXISTS ${roles.runtime}, ${roles.migration}`).run()
239
+ }
@@ -0,0 +1,21 @@
1
+ // The application's schema name (ADR-0017), runtime-neutral: the runner derives it, and Control classifies a commit's
2
+ // migrations against the same name in a Worker.
3
+
4
+ import { PostgresMigrationError } from './postgres-migration-error'
5
+
6
+ /** IAM's application id alphabet; `-` maps to `_`, which the alphabet lacks, so two ids never share a schema. */
7
+ const APP_ID = /^[a-z0-9][a-z0-9-]*$/
8
+ /** Postgres truncates identifiers to 63 bytes; `app_` takes four of them. */
9
+ const MAX_APP_ID_LENGTH = 59
10
+
11
+ export function validateApplicationId(appId: string): void {
12
+ if (!APP_ID.test(appId) || appId.length > MAX_APP_ID_LENGTH) {
13
+ throw new PostgresMigrationError(`Invalid application id for a database schema: ${appId} (lowercase letters, digits and -, at most 59)`)
14
+ }
15
+ }
16
+
17
+ /** `app_<id>`: the schema an application's migrations create and its data layer sets as its `search_path`. */
18
+ export function applicationDatabaseSchema(appId: string): string {
19
+ validateApplicationId(appId)
20
+ return `app_${appId.replaceAll('-', '_')}`
21
+ }
package/src/blob-s3.ts CHANGED
@@ -9,7 +9,15 @@
9
9
  // same round trip, whereas S3 needs a HEAD first. `get()` therefore costs one extra request on a hit.
10
10
  // Nothing here logs the credentials, the endpoint, or an error object that might quote a signed URL.
11
11
 
12
- import type { BlobStore } from '@fabrika/platform'
12
+ import {
13
+ type BlobStore,
14
+ type FileObjectStore,
15
+ ObjectExistsError,
16
+ type ObjectMetadata,
17
+ ObjectTooLargeError,
18
+ type ObjectWriteOptions,
19
+ type StoredObject,
20
+ } from '@fabrika/platform'
13
21
 
14
22
  /** Connection details for an S3-compatible bucket. `endpoint` is what makes it R2, MinIO, or AWS. */
15
23
  export interface S3BlobStoreOptions {
@@ -32,7 +40,7 @@ interface BlobBody {
32
40
  text(): Promise<string>
33
41
  }
34
42
 
35
- export class S3BlobStore implements BlobStore {
43
+ export class S3BlobStore implements BlobStore, FileObjectStore {
36
44
  constructor(private readonly client: Bun.S3Client) {}
37
45
 
38
46
  /** Build a store from plain connection options. Never logs them. */
@@ -76,4 +84,82 @@ export class S3BlobStore implements BlobStore {
76
84
  async delete(key: string): Promise<void> {
77
85
  await this.client.delete(key)
78
86
  }
87
+
88
+ async write(key: string, body: ReadableStream<Uint8Array>, options: ObjectWriteOptions): Promise<ObjectMetadata> {
89
+ // Bun's client sends no `If-None-Match`; a file's key is a fresh UUID behind a unique row, and a download checks
90
+ // the version the scan recorded, so this check refuses a reused key rather than racing a concurrent writer.
91
+ if ((await statOrNull(this.client.file(key))) !== null) {
92
+ await body.cancel()
93
+ throw new ObjectExistsError()
94
+ }
95
+ let size = 0
96
+ // Erroring the transform cancels `body` and fails the upload, so no more than one chunk past the limit is read.
97
+ const bounded = body.pipeThrough(
98
+ new TransformStream<Uint8Array, Uint8Array>({
99
+ transform(chunk, controller) {
100
+ size += chunk.byteLength
101
+ if (size > options.maxBytes) {
102
+ controller.error(new ObjectTooLargeError(options.maxBytes))
103
+ return
104
+ }
105
+ controller.enqueue(chunk)
106
+ },
107
+ }),
108
+ )
109
+ try {
110
+ await this.client.write(key, new Response(bounded), { type: options.contentType })
111
+ } catch (error) {
112
+ await this.client.delete(key).catch(() => undefined)
113
+ throw size > options.maxBytes ? new ObjectTooLargeError(options.maxBytes) : error
114
+ }
115
+ return { size, contentType: options.contentType }
116
+ }
117
+
118
+ read(key: string, signal?: AbortSignal): Promise<StoredObject | null> {
119
+ return this.#get(key, {}, signal)
120
+ }
121
+
122
+ readVersion(key: string, version: string, signal?: AbortSignal): Promise<StoredObject | null> {
123
+ return this.#get(key, { 'If-Match': version }, signal)
124
+ }
125
+
126
+ /**
127
+ * One GET through a short-lived presigned URL, so the ETag, the size and the body come from the same answer and a
128
+ * conditional header can be sent, which `Bun.S3File` does not offer. Errors never carry the URL: it is signed.
129
+ */
130
+ async #get(key: string, headers: Readonly<Record<string, string>>, signal: AbortSignal | undefined): Promise<StoredObject | null> {
131
+ const url = this.client.file(key).presign({ method: 'GET', expiresIn: PRESIGNED_READ_SECONDS })
132
+ let response: Response
133
+ try {
134
+ response = await fetch(url, { headers, ...(signal === undefined ? {} : { signal }) })
135
+ } catch {
136
+ throw new Error(signal?.aborted === true ? 'the object read was stopped' : 'the object store did not answer a read')
137
+ }
138
+ if (response.status === 404 || response.status === 412) {
139
+ await response.body?.cancel()
140
+ return null
141
+ }
142
+ const version = response.headers.get('ETag')
143
+ const size = Number(response.headers.get('Content-Length'))
144
+ if (!response.ok || response.body === null || version === null || !Number.isSafeInteger(size)) {
145
+ await response.body?.cancel()
146
+ throw new Error(`the object store answered a read with ${response.status}`)
147
+ }
148
+ const type = response.headers.get('Content-Type')
149
+ return { size, contentType: type === null || type === '' ? null : type, version, body: response.body }
150
+ }
151
+ }
152
+
153
+ const PRESIGNED_READ_SECONDS = 60
154
+
155
+ /** One HEAD; a missing key is `null`, any other failure propagates. */
156
+ async function statOrNull(file: Bun.S3File): Promise<Bun.S3Stats | null> {
157
+ try {
158
+ return await file.stat()
159
+ } catch (error) {
160
+ if (error instanceof Error && 'code' in error && error.code === 'NoSuchKey') {
161
+ return null
162
+ }
163
+ throw error
164
+ }
79
165
  }