@hyperfixation/db 0.1.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/LICENSE +21 -0
- package/dist/app-state.d.ts +12 -0
- package/dist/app-state.js +18 -0
- package/dist/boot-checks.d.ts +56 -0
- package/dist/boot-checks.js +228 -0
- package/dist/classify.d.ts +6 -0
- package/dist/classify.js +75 -0
- package/dist/control-plane.d.ts +75 -0
- package/dist/control-plane.js +155 -0
- package/dist/delete-guard.d.ts +32 -0
- package/dist/delete-guard.js +62 -0
- package/dist/fenced-client.d.ts +35 -0
- package/dist/fenced-client.js +153 -0
- package/dist/grant-ro.d.ts +23 -0
- package/dist/grant-ro.js +49 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +10 -0
- package/dist/internal/control-pool.d.ts +20 -0
- package/dist/internal/control-pool.js +17 -0
- package/dist/loader.d.ts +15 -0
- package/dist/loader.js +82 -0
- package/dist/migrate.d.ts +54 -0
- package/dist/migrate.js +143 -0
- package/dist/migration-policy.d.ts +14 -0
- package/dist/migration-policy.js +85 -0
- package/dist/migrator.d.ts +9 -0
- package/dist/migrator.js +9 -0
- package/dist/roles.d.ts +47 -0
- package/dist/roles.js +112 -0
- package/dist/schema/app.d.ts +235 -0
- package/dist/schema/app.js +23 -0
- package/dist/schema/approvals.d.ts +352 -0
- package/dist/schema/approvals.js +43 -0
- package/dist/schema/auth.d.ts +1272 -0
- package/dist/schema/auth.js +120 -0
- package/dist/schema/index.d.ts +7 -0
- package/dist/schema/index.js +7 -0
- package/dist/schema/ledger.d.ts +722 -0
- package/dist/schema/ledger.js +68 -0
- package/dist/schema/machinery.d.ts +1343 -0
- package/dist/schema/machinery.js +146 -0
- package/dist/schema/records.d.ts +15 -0
- package/dist/schema/records.js +16 -0
- package/dist/schema/runs.d.ts +213 -0
- package/dist/schema/runs.js +26 -0
- package/dist/step-pool.d.ts +26 -0
- package/dist/step-pool.js +57 -0
- package/migrations/0000_core_schema.sql +185 -0
- package/migrations/0001_llm_call_reservation_index.sql +4 -0
- package/migrations/0002_approvals.sql +25 -0
- package/migrations/0003_auth_invitation.sql +13 -0
- package/migrations/0004_machinery.sql +105 -0
- package/migrations/0005_nullable_activity_task_record.sql +4 -0
- package/migrations/0006_activity_score_key.sql +5 -0
- package/migrations/0007_score_spec_name.sql +3 -0
- package/migrations/meta/0000_snapshot.json +1238 -0
- package/migrations/meta/0001_snapshot.json +1238 -0
- package/migrations/meta/0002_snapshot.json +1426 -0
- package/migrations/meta/0003_snapshot.json +1516 -0
- package/migrations/meta/0004_snapshot.json +2353 -0
- package/migrations/meta/0005_snapshot.json +2353 -0
- package/migrations/meta/0006_snapshot.json +2415 -0
- package/migrations/meta/0007_snapshot.json +2427 -0
- package/migrations/meta/_journal.json +62 -0
- package/package.json +58 -0
package/dist/migrate.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
8
|
+
import { migrate as drizzleMigrate } from "drizzle-orm/node-postgres/migrator";
|
|
9
|
+
import { Client } from "pg";
|
|
10
|
+
import { checkE005 } from "./boot-checks.js";
|
|
11
|
+
import { installDeleteGuards } from "./delete-guard.js";
|
|
12
|
+
import { grantReadOnly } from "./grant-ro.js";
|
|
13
|
+
import { roleNames } from "./roles.js";
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
export const CORE_MIGRATIONS_TABLE = "hf_core_migrations";
|
|
16
|
+
export const CORE_MIGRATIONS_SCHEMA = "drizzle";
|
|
17
|
+
export const DBOS_SCHEMA = "dbos";
|
|
18
|
+
export const CORE_MIGRATIONS_DIR = fileURLToPath(new URL("../migrations", import.meta.url));
|
|
19
|
+
export class MigratorError extends Error {
|
|
20
|
+
constructor(message, options) {
|
|
21
|
+
super(message, options);
|
|
22
|
+
this.name = "MigratorError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The five-step migrator, run as the migrator role on every deploy:
|
|
27
|
+
* core migrations, app migrations, `dbos schema -s dbos -r hf_<app>`,
|
|
28
|
+
* delete-guard triggers, `hf_grant_ro`.
|
|
29
|
+
*
|
|
30
|
+
* Order is load-bearing at both ends. Step 3 runs on every deploy, not just the
|
|
31
|
+
* first, so an SDK upgrade that adds a system table grants it before the worker
|
|
32
|
+
* that needs it starts; steps 4 and 5 run last because both have to see the
|
|
33
|
+
* tables steps 1 and 2 just created.
|
|
34
|
+
*/
|
|
35
|
+
export async function migrate(migratorConnectionString, options) {
|
|
36
|
+
const names = roleNames(options.appName);
|
|
37
|
+
const applicationRole = options.applicationRole ?? names.application;
|
|
38
|
+
const readonlyRole = options.readonlyRole ?? names.readonly;
|
|
39
|
+
const coreMigrationsDir = options.coreMigrationsDir ?? CORE_MIGRATIONS_DIR;
|
|
40
|
+
const client = new Client({ connectionString: migratorConnectionString });
|
|
41
|
+
await client.connect();
|
|
42
|
+
try {
|
|
43
|
+
await assertProvisioned(client, options.appName, applicationRole);
|
|
44
|
+
const db = drizzle(client);
|
|
45
|
+
await drizzleMigrate(db, {
|
|
46
|
+
migrationsFolder: coreMigrationsDir,
|
|
47
|
+
migrationsTable: CORE_MIGRATIONS_TABLE,
|
|
48
|
+
migrationsSchema: CORE_MIGRATIONS_SCHEMA,
|
|
49
|
+
});
|
|
50
|
+
if (options.appMigrationsDir !== undefined) {
|
|
51
|
+
// E005 runs here as well as at boot: refusing the migration is the only
|
|
52
|
+
// point at which an app's attempt to reshape an hf_* table is still undone.
|
|
53
|
+
await checkE005(options.appMigrationsDir);
|
|
54
|
+
await drizzleMigrate(db, { migrationsFolder: options.appMigrationsDir });
|
|
55
|
+
}
|
|
56
|
+
const grantTo = options.dangerouslySkipApplicationRoleGrant === true ? null : applicationRole;
|
|
57
|
+
await runDbosSchema(migratorConnectionString, grantTo);
|
|
58
|
+
const deleteGuards = await installDeleteGuards(client, options.recordTables ?? []);
|
|
59
|
+
const grantRo = await grantReadOnly(client, readonlyRole);
|
|
60
|
+
return {
|
|
61
|
+
applicationRole,
|
|
62
|
+
dbosSchemaGranted: grantTo !== null,
|
|
63
|
+
appMigrationsApplied: options.appMigrationsDir !== undefined,
|
|
64
|
+
deleteGuards,
|
|
65
|
+
grantRo,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
await client.end();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const PROVISION_STATEMENT = `
|
|
73
|
+
SELECT current_user AS connected_role,
|
|
74
|
+
current_database() AS database_name,
|
|
75
|
+
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1) AS application_role_exists,
|
|
76
|
+
has_database_privilege(current_user, current_database(), 'CREATE') AS create_on_database,
|
|
77
|
+
coalesce(
|
|
78
|
+
has_schema_privilege(current_user, to_regnamespace('public'), 'CREATE'),
|
|
79
|
+
false
|
|
80
|
+
) AS create_on_public`;
|
|
81
|
+
/**
|
|
82
|
+
* The state `provisionRoles` leaves behind and the migrator cannot create for itself: a role
|
|
83
|
+
* cannot grant itself `CREATE`, and granting `dbos` to an application role that does not exist
|
|
84
|
+
* is not something a migration can fix. Each of these otherwise surfaces one at a time, several
|
|
85
|
+
* steps apart — the `CREATE SCHEMA "drizzle"` of step 1, then `public`, then step 3's `-r` —
|
|
86
|
+
* so a plain `CREATE DATABASE` costs three runs to discover what one message can say.
|
|
87
|
+
*/
|
|
88
|
+
async function assertProvisioned(client, appName, applicationRole) {
|
|
89
|
+
const { rows } = await client.query(PROVISION_STATEMENT, [applicationRole]);
|
|
90
|
+
const state = rows[0];
|
|
91
|
+
const missing = [];
|
|
92
|
+
if (!state.application_role_exists) {
|
|
93
|
+
missing.push(`the application role "${applicationRole}" does not exist`);
|
|
94
|
+
}
|
|
95
|
+
if (!state.create_on_database) {
|
|
96
|
+
missing.push(`"${state.connected_role}" may not CREATE in database "${state.database_name}"`);
|
|
97
|
+
}
|
|
98
|
+
if (!state.create_on_public) {
|
|
99
|
+
missing.push(`"${state.connected_role}" may not CREATE in schema "public"`);
|
|
100
|
+
}
|
|
101
|
+
if (missing.length === 0)
|
|
102
|
+
return;
|
|
103
|
+
throw new MigratorError(`database "${state.database_name}" is not provisioned for ${appName}: ${missing.join("; ")}. ` +
|
|
104
|
+
"Run `hf migrate` in the app directory, which provisions the roles before running this; " +
|
|
105
|
+
"a deployed database is provisioned by provisionRoles() from @hyperfixation/db/migrator.");
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* `dbos schema -s dbos -r <role>`: creates the DBOS system schema and grants the
|
|
109
|
+
* application role on it. The `-r` flag is the only path to those grants — the
|
|
110
|
+
* SDK's `getDbosSchemaPermissionsSql` is the sole source of `GRANT` statements
|
|
111
|
+
* anywhere in it — and without them the worker cannot launch at all.
|
|
112
|
+
*/
|
|
113
|
+
export async function runDbosSchema(migratorConnectionString, applicationRole, schema = DBOS_SCHEMA) {
|
|
114
|
+
const cli = resolveDbosCli();
|
|
115
|
+
const args = [cli, "schema", "-s", schema];
|
|
116
|
+
if (applicationRole !== null)
|
|
117
|
+
args.push("-r", applicationRole);
|
|
118
|
+
args.push(migratorConnectionString);
|
|
119
|
+
try {
|
|
120
|
+
await execFileAsync(process.execPath, args, { encoding: "utf8" });
|
|
121
|
+
}
|
|
122
|
+
catch (cause) {
|
|
123
|
+
throw new MigratorError(`dbos schema -s ${schema}${applicationRole === null ? "" : ` -r ${applicationRole}`} failed: ` +
|
|
124
|
+
cause.message, { cause });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function resolveDbosCli() {
|
|
128
|
+
const require = createRequire(import.meta.url);
|
|
129
|
+
let dir = path.dirname(require.resolve("@dbos-inc/dbos-sdk"));
|
|
130
|
+
for (;;) {
|
|
131
|
+
const manifest = path.join(dir, "package.json");
|
|
132
|
+
if (existsSync(manifest)) {
|
|
133
|
+
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
134
|
+
if (pkg.name === "@dbos-inc/dbos-sdk" && pkg.bin?.dbos !== undefined) {
|
|
135
|
+
return path.resolve(dir, pkg.bin.dbos);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const parent = path.dirname(dir);
|
|
139
|
+
if (parent === dir)
|
|
140
|
+
throw new MigratorError("could not locate the @dbos-inc/dbos-sdk CLI");
|
|
141
|
+
dir = parent;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare class MigrationPolicyViolation extends Error {
|
|
2
|
+
readonly statement: string | undefined;
|
|
3
|
+
constructor(message: string, statement?: string);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Throws unless every statement in an app migration is on the allowlist.
|
|
7
|
+
*
|
|
8
|
+
* Default-deny: a statement kind that is not listed, and any SQL the parser
|
|
9
|
+
* cannot read, is a violation. `CREATE PROCEDURE` reaches the unparseable
|
|
10
|
+
* branch — pgsql-ast-parser has no node for it — and `CREATE FUNCTION` its own;
|
|
11
|
+
* both must be refused however they are spelled, because either can hide a
|
|
12
|
+
* write behind something a later caller reads as a read.
|
|
13
|
+
*/
|
|
14
|
+
export declare function assertAppMigrationAllowed(sql: string): void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { parse } from "pgsql-ast-parser";
|
|
2
|
+
export class MigrationPolicyViolation extends Error {
|
|
3
|
+
statement;
|
|
4
|
+
constructor(message, statement) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "MigrationPolicyViolation";
|
|
7
|
+
this.statement = statement;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Throws unless every statement in an app migration is on the allowlist.
|
|
12
|
+
*
|
|
13
|
+
* Default-deny: a statement kind that is not listed, and any SQL the parser
|
|
14
|
+
* cannot read, is a violation. `CREATE PROCEDURE` reaches the unparseable
|
|
15
|
+
* branch — pgsql-ast-parser has no node for it — and `CREATE FUNCTION` its own;
|
|
16
|
+
* both must be refused however they are spelled, because either can hide a
|
|
17
|
+
* write behind something a later caller reads as a read.
|
|
18
|
+
*/
|
|
19
|
+
export function assertAppMigrationAllowed(sql) {
|
|
20
|
+
for (const statement of splitStatements(sql)) {
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = parse(statement);
|
|
24
|
+
}
|
|
25
|
+
catch (cause) {
|
|
26
|
+
throw new MigrationPolicyViolation(`app migrations may not contain SQL the migration policy cannot parse: ${cause.message}`, statement);
|
|
27
|
+
}
|
|
28
|
+
for (const node of parsed)
|
|
29
|
+
assertStatementAllowed(node, statement);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function assertStatementAllowed(node, statement) {
|
|
33
|
+
switch (node.type) {
|
|
34
|
+
case "create table":
|
|
35
|
+
return;
|
|
36
|
+
case "create index":
|
|
37
|
+
if (node.unique) {
|
|
38
|
+
throw new MigrationPolicyViolation("app migrations may not create unique indexes", statement);
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
case "create extension":
|
|
42
|
+
if (!node.ifNotExists) {
|
|
43
|
+
throw new MigrationPolicyViolation("CREATE EXTENSION must be IF NOT EXISTS in an app migration", statement);
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
case "alter table":
|
|
47
|
+
assertAlterTableAllowed(node, statement);
|
|
48
|
+
return;
|
|
49
|
+
case "create function":
|
|
50
|
+
throw new MigrationPolicyViolation("app migrations may not contain CREATE FUNCTION", statement);
|
|
51
|
+
default:
|
|
52
|
+
throw new MigrationPolicyViolation(`app migrations may not contain \`${node.type}\` statements`, statement);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function assertAlterTableAllowed(node, statement) {
|
|
56
|
+
for (const change of node.changes) {
|
|
57
|
+
switch (change.type) {
|
|
58
|
+
case "add column": {
|
|
59
|
+
const constraints = change.column.constraints ?? [];
|
|
60
|
+
const notNull = constraints.some((c) => c.type === "not null");
|
|
61
|
+
const hasDefault = constraints.some((c) => c.type === "default");
|
|
62
|
+
if (notNull && !hasDefault) {
|
|
63
|
+
throw new MigrationPolicyViolation("ADD COLUMN must be nullable or carry a default in an app migration", statement);
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "alter column":
|
|
68
|
+
if (change.alter.type !== "drop not null") {
|
|
69
|
+
throw new MigrationPolicyViolation(`app migrations may not \`ALTER COLUMN … ${change.alter.type.toUpperCase()}\``, statement);
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
default:
|
|
73
|
+
throw new MigrationPolicyViolation(`app migrations may not \`ALTER TABLE … ${change.type.toUpperCase()}\``, statement);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Drizzle Kit writes one migration file as several statements separated by this
|
|
78
|
+
// marker; the parser is happy with either form, but splitting keeps a violation's
|
|
79
|
+
// `statement` pointed at the offending statement rather than the whole file.
|
|
80
|
+
function splitStatements(sql) {
|
|
81
|
+
return sql
|
|
82
|
+
.split("--> statement-breakpoint")
|
|
83
|
+
.map((s) => s.trim())
|
|
84
|
+
.filter((s) => s.length > 0);
|
|
85
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy-time surface, kept off `.` so the web and worker bundles never pull the migrator's
|
|
3
|
+
* `node:child_process` and filesystem dependencies in behind the schema.
|
|
4
|
+
*/
|
|
5
|
+
export { assertAppMigrationAllowed, MigrationPolicyViolation } from "./migration-policy.js";
|
|
6
|
+
export { assertAppName, provisionRoles, roleNames, RoleProvisioningError, type ProvisionedRoles, type ProvisionRolesOptions, type RoleNames, } from "./roles.js";
|
|
7
|
+
export { migrate, runDbosSchema, MigratorError, CORE_MIGRATIONS_DIR, CORE_MIGRATIONS_SCHEMA, CORE_MIGRATIONS_TABLE, DBOS_SCHEMA, type MigrateOptions, type MigrateResult, } from "./migrate.js";
|
|
8
|
+
export { installDeleteGuards, DELETE_GUARD_FUNCTION, DELETE_GUARD_REFERENCING_TABLES, type DeleteGuardResult, type RecordTable, } from "./delete-guard.js";
|
|
9
|
+
export { grantReadOnly, GRANT_RO_EXCLUDED_TABLES, type GrantRoResult } from "./grant-ro.js";
|
package/dist/migrator.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy-time surface, kept off `.` so the web and worker bundles never pull the migrator's
|
|
3
|
+
* `node:child_process` and filesystem dependencies in behind the schema.
|
|
4
|
+
*/
|
|
5
|
+
export { assertAppMigrationAllowed, MigrationPolicyViolation } from "./migration-policy.js";
|
|
6
|
+
export { assertAppName, provisionRoles, roleNames, RoleProvisioningError, } from "./roles.js";
|
|
7
|
+
export { migrate, runDbosSchema, MigratorError, CORE_MIGRATIONS_DIR, CORE_MIGRATIONS_SCHEMA, CORE_MIGRATIONS_TABLE, DBOS_SCHEMA, } from "./migrate.js";
|
|
8
|
+
export { installDeleteGuards, DELETE_GUARD_FUNCTION, DELETE_GUARD_REFERENCING_TABLES, } from "./delete-guard.js";
|
|
9
|
+
export { grantReadOnly, GRANT_RO_EXCLUDED_TABLES } from "./grant-ro.js";
|
package/dist/roles.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export declare class RoleProvisioningError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export interface RoleNames {
|
|
5
|
+
/** Owns every `hf_*` object and the `dbos` schema; the only role `migrate` uses. */
|
|
6
|
+
migrator: string;
|
|
7
|
+
/** Used by `web` and `worker`; owns nothing. */
|
|
8
|
+
application: string;
|
|
9
|
+
/** Metabase's read-only role; created only when a password is supplied. */
|
|
10
|
+
readonly: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ProvisionRolesOptions {
|
|
13
|
+
appName: string;
|
|
14
|
+
databaseName: string;
|
|
15
|
+
migratorPassword?: string;
|
|
16
|
+
applicationPassword?: string;
|
|
17
|
+
/** Supply to create the `_ro` role as well; omit to leave it uncreated. */
|
|
18
|
+
readonlyPassword?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ProvisionedRoles extends RoleNames {
|
|
21
|
+
migratorPassword: string;
|
|
22
|
+
applicationPassword: string;
|
|
23
|
+
readonlyPassword: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
export declare function roleNames(appName: string): RoleNames;
|
|
26
|
+
export declare function assertAppName(appName: string): void;
|
|
27
|
+
/**
|
|
28
|
+
* Creates the migrator role, the application role, and optionally the read-only
|
|
29
|
+
* role, and gives each the database- and schema-level privileges the migrator
|
|
30
|
+
* cannot give itself later.
|
|
31
|
+
*
|
|
32
|
+
* The application role's privileges on `hf_*` tables come from default
|
|
33
|
+
* privileges recorded here *for the migrator role*, not from a grant step in the
|
|
34
|
+
* migrator: every deploy's migrations create tables as the migrator, so tables
|
|
35
|
+
* added by a later deploy are granted at creation with no extra step to forget.
|
|
36
|
+
* Privileges on `dbos.*` are a separate matter entirely — only
|
|
37
|
+
* `dbos schema -r <role>` grants those (see `runDbosSchema`).
|
|
38
|
+
*
|
|
39
|
+
* `adminConnectionString` must be a superuser (or role-creating) connection; it
|
|
40
|
+
* may point at any database on the cluster, since the target database is
|
|
41
|
+
* reconnected to by name.
|
|
42
|
+
*/
|
|
43
|
+
export declare function provisionRoles(adminConnectionString: string, options: ProvisionRolesOptions): Promise<ProvisionedRoles>;
|
|
44
|
+
export declare function generatePassword(): string;
|
|
45
|
+
/** Swaps the database of a connection string, keeping credentials and options. */
|
|
46
|
+
export declare function withDatabase(connectionString: string, databaseName: string): string;
|
|
47
|
+
export declare function quoteIdent(name: string): string;
|
package/dist/roles.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { Client } from "pg";
|
|
3
|
+
/** The identifier rule `hf new` validates an app name against. */
|
|
4
|
+
const APP_NAME = /^[a-z][a-z0-9_]{0,62}$/;
|
|
5
|
+
export class RoleProvisioningError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "RoleProvisioningError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function roleNames(appName) {
|
|
12
|
+
assertAppName(appName);
|
|
13
|
+
return {
|
|
14
|
+
migrator: `hf_${appName}_migrator`,
|
|
15
|
+
application: `hf_${appName}`,
|
|
16
|
+
readonly: `hf_${appName}_ro`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function assertAppName(appName) {
|
|
20
|
+
if (!APP_NAME.test(appName)) {
|
|
21
|
+
throw new RoleProvisioningError(`app name must match ${APP_NAME.source}, got ${JSON.stringify(appName)}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Creates the migrator role, the application role, and optionally the read-only
|
|
26
|
+
* role, and gives each the database- and schema-level privileges the migrator
|
|
27
|
+
* cannot give itself later.
|
|
28
|
+
*
|
|
29
|
+
* The application role's privileges on `hf_*` tables come from default
|
|
30
|
+
* privileges recorded here *for the migrator role*, not from a grant step in the
|
|
31
|
+
* migrator: every deploy's migrations create tables as the migrator, so tables
|
|
32
|
+
* added by a later deploy are granted at creation with no extra step to forget.
|
|
33
|
+
* Privileges on `dbos.*` are a separate matter entirely — only
|
|
34
|
+
* `dbos schema -r <role>` grants those (see `runDbosSchema`).
|
|
35
|
+
*
|
|
36
|
+
* `adminConnectionString` must be a superuser (or role-creating) connection; it
|
|
37
|
+
* may point at any database on the cluster, since the target database is
|
|
38
|
+
* reconnected to by name.
|
|
39
|
+
*/
|
|
40
|
+
export async function provisionRoles(adminConnectionString, options) {
|
|
41
|
+
const names = roleNames(options.appName);
|
|
42
|
+
assertIdentifier(options.databaseName, "database name");
|
|
43
|
+
const provisioned = {
|
|
44
|
+
...names,
|
|
45
|
+
migratorPassword: options.migratorPassword ?? generatePassword(),
|
|
46
|
+
applicationPassword: options.applicationPassword ?? generatePassword(),
|
|
47
|
+
readonlyPassword: options.readonlyPassword,
|
|
48
|
+
};
|
|
49
|
+
const admin = new Client({ connectionString: adminConnectionString });
|
|
50
|
+
await admin.connect();
|
|
51
|
+
try {
|
|
52
|
+
await createLoginRole(admin, names.migrator, provisioned.migratorPassword, null);
|
|
53
|
+
await createLoginRole(admin, names.application, provisioned.applicationPassword, 25);
|
|
54
|
+
if (provisioned.readonlyPassword !== undefined) {
|
|
55
|
+
await createLoginRole(admin, names.readonly, provisioned.readonlyPassword, 4);
|
|
56
|
+
}
|
|
57
|
+
const db = quoteIdent(options.databaseName);
|
|
58
|
+
await admin.query(`GRANT CONNECT, CREATE ON DATABASE ${db} TO ${quoteIdent(names.migrator)}`);
|
|
59
|
+
await admin.query(`GRANT CONNECT ON DATABASE ${db} TO ${quoteIdent(names.application)}`);
|
|
60
|
+
if (provisioned.readonlyPassword !== undefined) {
|
|
61
|
+
await admin.query(`GRANT CONNECT ON DATABASE ${db} TO ${quoteIdent(names.readonly)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await admin.end();
|
|
66
|
+
}
|
|
67
|
+
const target = new Client({
|
|
68
|
+
connectionString: withDatabase(adminConnectionString, options.databaseName),
|
|
69
|
+
});
|
|
70
|
+
await target.connect();
|
|
71
|
+
try {
|
|
72
|
+
await target.query(`GRANT CREATE, USAGE ON SCHEMA public TO ${quoteIdent(names.migrator)}`);
|
|
73
|
+
await target.query(`GRANT USAGE ON SCHEMA public TO ${quoteIdent(names.application)}`);
|
|
74
|
+
await target.query(`ALTER DEFAULT PRIVILEGES FOR ROLE ${quoteIdent(names.migrator)} IN SCHEMA public ` +
|
|
75
|
+
`GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${quoteIdent(names.application)}`);
|
|
76
|
+
await target.query(`ALTER DEFAULT PRIVILEGES FOR ROLE ${quoteIdent(names.migrator)} IN SCHEMA public ` +
|
|
77
|
+
`GRANT USAGE, SELECT ON SEQUENCES TO ${quoteIdent(names.application)}`);
|
|
78
|
+
if (provisioned.readonlyPassword !== undefined) {
|
|
79
|
+
await target.query(`GRANT USAGE ON SCHEMA public TO ${quoteIdent(names.readonly)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await target.end();
|
|
84
|
+
}
|
|
85
|
+
return provisioned;
|
|
86
|
+
}
|
|
87
|
+
async function createLoginRole(admin, role, password, connectionLimit) {
|
|
88
|
+
const { rows } = await admin.query("SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1) AS exists", [role]);
|
|
89
|
+
const verb = rows[0]?.exists ? "ALTER" : "CREATE";
|
|
90
|
+
const limit = connectionLimit === null ? "" : ` CONNECTION LIMIT ${connectionLimit}`;
|
|
91
|
+
await admin.query(`${verb} ROLE ${quoteIdent(role)} LOGIN PASSWORD ${quoteLiteral(password)}${limit}`);
|
|
92
|
+
}
|
|
93
|
+
export function generatePassword() {
|
|
94
|
+
return randomBytes(24).toString("base64url");
|
|
95
|
+
}
|
|
96
|
+
/** Swaps the database of a connection string, keeping credentials and options. */
|
|
97
|
+
export function withDatabase(connectionString, databaseName) {
|
|
98
|
+
const url = new URL(connectionString);
|
|
99
|
+
url.pathname = `/${encodeURIComponent(databaseName)}`;
|
|
100
|
+
return url.toString();
|
|
101
|
+
}
|
|
102
|
+
export function quoteIdent(name) {
|
|
103
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
104
|
+
}
|
|
105
|
+
function quoteLiteral(value) {
|
|
106
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
107
|
+
}
|
|
108
|
+
function assertIdentifier(value, what) {
|
|
109
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(value)) {
|
|
110
|
+
throw new RoleProvisioningError(`${what} must be a plain identifier, got ${JSON.stringify(value)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
export declare const hfAppState: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
2
|
+
name: "hf_app_state";
|
|
3
|
+
schema: undefined;
|
|
4
|
+
columns: {
|
|
5
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
6
|
+
name: "id";
|
|
7
|
+
tableName: "hf_app_state";
|
|
8
|
+
dataType: "number";
|
|
9
|
+
columnType: "PgInteger";
|
|
10
|
+
data: number;
|
|
11
|
+
driverParam: string | number;
|
|
12
|
+
notNull: true;
|
|
13
|
+
hasDefault: true;
|
|
14
|
+
isPrimaryKey: true;
|
|
15
|
+
isAutoincrement: false;
|
|
16
|
+
hasRuntimeDefault: false;
|
|
17
|
+
enumValues: undefined;
|
|
18
|
+
baseColumn: never;
|
|
19
|
+
identity: undefined;
|
|
20
|
+
generated: undefined;
|
|
21
|
+
}, {}, {}>;
|
|
22
|
+
paused: import("drizzle-orm/pg-core").PgColumn<{
|
|
23
|
+
name: "paused";
|
|
24
|
+
tableName: "hf_app_state";
|
|
25
|
+
dataType: "boolean";
|
|
26
|
+
columnType: "PgBoolean";
|
|
27
|
+
data: boolean;
|
|
28
|
+
driverParam: boolean;
|
|
29
|
+
notNull: true;
|
|
30
|
+
hasDefault: true;
|
|
31
|
+
isPrimaryKey: false;
|
|
32
|
+
isAutoincrement: false;
|
|
33
|
+
hasRuntimeDefault: false;
|
|
34
|
+
enumValues: undefined;
|
|
35
|
+
baseColumn: never;
|
|
36
|
+
identity: undefined;
|
|
37
|
+
generated: undefined;
|
|
38
|
+
}, {}, {}>;
|
|
39
|
+
pausedBy: import("drizzle-orm/pg-core").PgColumn<{
|
|
40
|
+
name: "paused_by";
|
|
41
|
+
tableName: "hf_app_state";
|
|
42
|
+
dataType: "string";
|
|
43
|
+
columnType: "PgText";
|
|
44
|
+
data: string;
|
|
45
|
+
driverParam: string;
|
|
46
|
+
notNull: false;
|
|
47
|
+
hasDefault: false;
|
|
48
|
+
isPrimaryKey: false;
|
|
49
|
+
isAutoincrement: false;
|
|
50
|
+
hasRuntimeDefault: false;
|
|
51
|
+
enumValues: [string, ...string[]];
|
|
52
|
+
baseColumn: never;
|
|
53
|
+
identity: undefined;
|
|
54
|
+
generated: undefined;
|
|
55
|
+
}, {}, {}>;
|
|
56
|
+
budgetUsd: import("drizzle-orm/pg-core").PgColumn<{
|
|
57
|
+
name: "budget_usd";
|
|
58
|
+
tableName: "hf_app_state";
|
|
59
|
+
dataType: "string";
|
|
60
|
+
columnType: "PgNumeric";
|
|
61
|
+
data: string;
|
|
62
|
+
driverParam: string;
|
|
63
|
+
notNull: true;
|
|
64
|
+
hasDefault: false;
|
|
65
|
+
isPrimaryKey: false;
|
|
66
|
+
isAutoincrement: false;
|
|
67
|
+
hasRuntimeDefault: false;
|
|
68
|
+
enumValues: undefined;
|
|
69
|
+
baseColumn: never;
|
|
70
|
+
identity: undefined;
|
|
71
|
+
generated: undefined;
|
|
72
|
+
}, {}, {}>;
|
|
73
|
+
readTokenHash: import("drizzle-orm/pg-core").PgColumn<{
|
|
74
|
+
name: "read_token_hash";
|
|
75
|
+
tableName: "hf_app_state";
|
|
76
|
+
dataType: "string";
|
|
77
|
+
columnType: "PgText";
|
|
78
|
+
data: string;
|
|
79
|
+
driverParam: string;
|
|
80
|
+
notNull: false;
|
|
81
|
+
hasDefault: false;
|
|
82
|
+
isPrimaryKey: false;
|
|
83
|
+
isAutoincrement: false;
|
|
84
|
+
hasRuntimeDefault: false;
|
|
85
|
+
enumValues: [string, ...string[]];
|
|
86
|
+
baseColumn: never;
|
|
87
|
+
identity: undefined;
|
|
88
|
+
generated: undefined;
|
|
89
|
+
}, {}, {}>;
|
|
90
|
+
writeTokenHash: import("drizzle-orm/pg-core").PgColumn<{
|
|
91
|
+
name: "write_token_hash";
|
|
92
|
+
tableName: "hf_app_state";
|
|
93
|
+
dataType: "string";
|
|
94
|
+
columnType: "PgText";
|
|
95
|
+
data: string;
|
|
96
|
+
driverParam: string;
|
|
97
|
+
notNull: false;
|
|
98
|
+
hasDefault: false;
|
|
99
|
+
isPrimaryKey: false;
|
|
100
|
+
isAutoincrement: false;
|
|
101
|
+
hasRuntimeDefault: false;
|
|
102
|
+
enumValues: [string, ...string[]];
|
|
103
|
+
baseColumn: never;
|
|
104
|
+
identity: undefined;
|
|
105
|
+
generated: undefined;
|
|
106
|
+
}, {}, {}>;
|
|
107
|
+
};
|
|
108
|
+
dialect: "pg";
|
|
109
|
+
}>;
|
|
110
|
+
export declare const hfAudit: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
111
|
+
name: "hf_audit";
|
|
112
|
+
schema: undefined;
|
|
113
|
+
columns: {
|
|
114
|
+
id: import("drizzle-orm/pg-core").PgColumn<{
|
|
115
|
+
name: "id";
|
|
116
|
+
tableName: "hf_audit";
|
|
117
|
+
dataType: "number";
|
|
118
|
+
columnType: "PgBigInt53";
|
|
119
|
+
data: number;
|
|
120
|
+
driverParam: string | number;
|
|
121
|
+
notNull: true;
|
|
122
|
+
hasDefault: true;
|
|
123
|
+
isPrimaryKey: true;
|
|
124
|
+
isAutoincrement: false;
|
|
125
|
+
hasRuntimeDefault: false;
|
|
126
|
+
enumValues: undefined;
|
|
127
|
+
baseColumn: never;
|
|
128
|
+
identity: "always";
|
|
129
|
+
generated: undefined;
|
|
130
|
+
}, {}, {}>;
|
|
131
|
+
actorId: import("drizzle-orm/pg-core").PgColumn<{
|
|
132
|
+
name: "actor_id";
|
|
133
|
+
tableName: "hf_audit";
|
|
134
|
+
dataType: "string";
|
|
135
|
+
columnType: "PgText";
|
|
136
|
+
data: string;
|
|
137
|
+
driverParam: string;
|
|
138
|
+
notNull: false;
|
|
139
|
+
hasDefault: false;
|
|
140
|
+
isPrimaryKey: false;
|
|
141
|
+
isAutoincrement: false;
|
|
142
|
+
hasRuntimeDefault: false;
|
|
143
|
+
enumValues: [string, ...string[]];
|
|
144
|
+
baseColumn: never;
|
|
145
|
+
identity: undefined;
|
|
146
|
+
generated: undefined;
|
|
147
|
+
}, {}, {}>;
|
|
148
|
+
action: import("drizzle-orm/pg-core").PgColumn<{
|
|
149
|
+
name: "action";
|
|
150
|
+
tableName: "hf_audit";
|
|
151
|
+
dataType: "string";
|
|
152
|
+
columnType: "PgText";
|
|
153
|
+
data: string;
|
|
154
|
+
driverParam: string;
|
|
155
|
+
notNull: true;
|
|
156
|
+
hasDefault: false;
|
|
157
|
+
isPrimaryKey: false;
|
|
158
|
+
isAutoincrement: false;
|
|
159
|
+
hasRuntimeDefault: false;
|
|
160
|
+
enumValues: [string, ...string[]];
|
|
161
|
+
baseColumn: never;
|
|
162
|
+
identity: undefined;
|
|
163
|
+
generated: undefined;
|
|
164
|
+
}, {}, {}>;
|
|
165
|
+
targetType: import("drizzle-orm/pg-core").PgColumn<{
|
|
166
|
+
name: "target_type";
|
|
167
|
+
tableName: "hf_audit";
|
|
168
|
+
dataType: "string";
|
|
169
|
+
columnType: "PgText";
|
|
170
|
+
data: string;
|
|
171
|
+
driverParam: string;
|
|
172
|
+
notNull: false;
|
|
173
|
+
hasDefault: false;
|
|
174
|
+
isPrimaryKey: false;
|
|
175
|
+
isAutoincrement: false;
|
|
176
|
+
hasRuntimeDefault: false;
|
|
177
|
+
enumValues: [string, ...string[]];
|
|
178
|
+
baseColumn: never;
|
|
179
|
+
identity: undefined;
|
|
180
|
+
generated: undefined;
|
|
181
|
+
}, {}, {}>;
|
|
182
|
+
targetId: import("drizzle-orm/pg-core").PgColumn<{
|
|
183
|
+
name: "target_id";
|
|
184
|
+
tableName: "hf_audit";
|
|
185
|
+
dataType: "string";
|
|
186
|
+
columnType: "PgText";
|
|
187
|
+
data: string;
|
|
188
|
+
driverParam: string;
|
|
189
|
+
notNull: false;
|
|
190
|
+
hasDefault: false;
|
|
191
|
+
isPrimaryKey: false;
|
|
192
|
+
isAutoincrement: false;
|
|
193
|
+
hasRuntimeDefault: false;
|
|
194
|
+
enumValues: [string, ...string[]];
|
|
195
|
+
baseColumn: never;
|
|
196
|
+
identity: undefined;
|
|
197
|
+
generated: undefined;
|
|
198
|
+
}, {}, {}>;
|
|
199
|
+
meta: import("drizzle-orm/pg-core").PgColumn<{
|
|
200
|
+
name: "meta";
|
|
201
|
+
tableName: "hf_audit";
|
|
202
|
+
dataType: "json";
|
|
203
|
+
columnType: "PgJsonb";
|
|
204
|
+
data: unknown;
|
|
205
|
+
driverParam: unknown;
|
|
206
|
+
notNull: false;
|
|
207
|
+
hasDefault: false;
|
|
208
|
+
isPrimaryKey: false;
|
|
209
|
+
isAutoincrement: false;
|
|
210
|
+
hasRuntimeDefault: false;
|
|
211
|
+
enumValues: undefined;
|
|
212
|
+
baseColumn: never;
|
|
213
|
+
identity: undefined;
|
|
214
|
+
generated: undefined;
|
|
215
|
+
}, {}, {}>;
|
|
216
|
+
at: import("drizzle-orm/pg-core").PgColumn<{
|
|
217
|
+
name: "at";
|
|
218
|
+
tableName: "hf_audit";
|
|
219
|
+
dataType: "date";
|
|
220
|
+
columnType: "PgTimestamp";
|
|
221
|
+
data: Date;
|
|
222
|
+
driverParam: string;
|
|
223
|
+
notNull: true;
|
|
224
|
+
hasDefault: true;
|
|
225
|
+
isPrimaryKey: false;
|
|
226
|
+
isAutoincrement: false;
|
|
227
|
+
hasRuntimeDefault: false;
|
|
228
|
+
enumValues: undefined;
|
|
229
|
+
baseColumn: never;
|
|
230
|
+
identity: undefined;
|
|
231
|
+
generated: undefined;
|
|
232
|
+
}, {}, {}>;
|
|
233
|
+
};
|
|
234
|
+
dialect: "pg";
|
|
235
|
+
}>;
|