@govcore/setup 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/chunk-PZIHAYWT.js +96 -0
- package/dist/chunk-PZIHAYWT.js.map +1 -0
- package/dist/cli.d.ts +35 -0
- package/dist/cli.js +84 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rob Allred
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import postgres from "postgres";
|
|
4
|
+
import { organizations, users } from "@govcore/schema";
|
|
5
|
+
import { slugify, upsertMembership } from "@govcore/tenancy";
|
|
6
|
+
import { hashPassword, validatePassword } from "@govcore/auth";
|
|
7
|
+
import { writeAuditLog } from "@govcore/audit";
|
|
8
|
+
|
|
9
|
+
// src/identifier.ts
|
|
10
|
+
var IDENTIFIER_RE = /^[a-z_][a-z0-9_]*$/i;
|
|
11
|
+
function assertSafeIdentifier(name, kind) {
|
|
12
|
+
if (!IDENTIFIER_RE.test(name)) {
|
|
13
|
+
throw new Error(`Unsafe ${kind} identifier ${JSON.stringify(name)} \u2014 expected /^[A-Za-z_][A-Za-z0-9_]*$/`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// src/index.ts
|
|
18
|
+
async function provisionRuntimeRole(opts) {
|
|
19
|
+
assertSafeIdentifier(opts.role, "role");
|
|
20
|
+
const schemas = opts.schemas ?? ["govcore"];
|
|
21
|
+
schemas.forEach((s) => assertSafeIdentifier(s, "schema"));
|
|
22
|
+
const log = opts.log ?? (() => {
|
|
23
|
+
});
|
|
24
|
+
const escapedPassword = opts.password.replace(/'/g, "''");
|
|
25
|
+
const sql = postgres(opts.connectionString, { max: 1 });
|
|
26
|
+
try {
|
|
27
|
+
await sql.unsafe(
|
|
28
|
+
`DO $$ BEGIN CREATE ROLE ${opts.role} LOGIN PASSWORD '${escapedPassword}';
|
|
29
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;`
|
|
30
|
+
);
|
|
31
|
+
for (const schema of schemas) {
|
|
32
|
+
await sql.unsafe(`GRANT USAGE ON SCHEMA ${schema} TO ${opts.role}`);
|
|
33
|
+
await sql.unsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${schema} TO ${opts.role}`);
|
|
34
|
+
await sql.unsafe(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${schema} TO ${opts.role}`);
|
|
35
|
+
await sql.unsafe(
|
|
36
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${schema} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${opts.role}`
|
|
37
|
+
);
|
|
38
|
+
await sql.unsafe(
|
|
39
|
+
`ALTER DEFAULT PRIVILEGES IN SCHEMA ${schema} GRANT USAGE, SELECT ON SEQUENCES TO ${opts.role}`
|
|
40
|
+
);
|
|
41
|
+
log(`govcore-setup: granted ${opts.role} on schema ${schema}`);
|
|
42
|
+
}
|
|
43
|
+
} finally {
|
|
44
|
+
await sql.end();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function bootstrap(db, opts) {
|
|
48
|
+
const name = opts.organization.name.trim();
|
|
49
|
+
const email = opts.admin.email.trim().toLowerCase();
|
|
50
|
+
if (!name || !email || !opts.admin.password) {
|
|
51
|
+
return { ok: false, reason: "missing-fields" };
|
|
52
|
+
}
|
|
53
|
+
const validation = validatePassword(opts.admin.password, opts.policy);
|
|
54
|
+
if (!validation.valid) return { ok: false, reason: "weak-password", message: validation.message };
|
|
55
|
+
const [existing] = await db.select({ id: organizations.id }).from(organizations).limit(1);
|
|
56
|
+
if (existing) return { ok: false, reason: "already-bootstrapped" };
|
|
57
|
+
const adminRole = opts.adminRole ?? "admin";
|
|
58
|
+
const slug = opts.organization.slug?.trim() || slugify(name);
|
|
59
|
+
const passwordHash = await hashPassword(opts.admin.password);
|
|
60
|
+
const adminUserId = randomUUID();
|
|
61
|
+
const result = await db.transaction(async (tx) => {
|
|
62
|
+
const [org] = await tx.insert(organizations).values({ name, slug }).returning();
|
|
63
|
+
await tx.insert(users).values({
|
|
64
|
+
id: adminUserId,
|
|
65
|
+
organizationId: org.id,
|
|
66
|
+
email,
|
|
67
|
+
name: opts.admin.name?.trim() || null,
|
|
68
|
+
passwordHash,
|
|
69
|
+
role: adminRole,
|
|
70
|
+
instanceRole: "instance_admin"
|
|
71
|
+
});
|
|
72
|
+
await upsertMembership(tx, {
|
|
73
|
+
userId: adminUserId,
|
|
74
|
+
organizationId: org.id,
|
|
75
|
+
role: adminRole,
|
|
76
|
+
isPrimary: true
|
|
77
|
+
});
|
|
78
|
+
await writeAuditLog(tx, {
|
|
79
|
+
action: "platform.bootstrap",
|
|
80
|
+
entityType: "organization",
|
|
81
|
+
entityId: org.id,
|
|
82
|
+
organizationId: org.id,
|
|
83
|
+
userId: adminUserId,
|
|
84
|
+
after: { organization: name, slug, admin: email, instanceRole: "instance_admin" }
|
|
85
|
+
});
|
|
86
|
+
return { organizationId: org.id, adminUserId };
|
|
87
|
+
});
|
|
88
|
+
return { ok: true, ...result };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export {
|
|
92
|
+
assertSafeIdentifier,
|
|
93
|
+
provisionRuntimeRole,
|
|
94
|
+
bootstrap
|
|
95
|
+
};
|
|
96
|
+
//# sourceMappingURL=chunk-PZIHAYWT.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/identifier.ts"],"sourcesContent":["// @govcore/setup — first-run bootstrap for an empty instance.\n//\n// Every consumer reinvents this: GovCRM's seed.ts and GovEA's setup action both\n// (a) provision the non-owner runtime role + grants (the two-role split — getting\n// the grants wrong silently breaks RLS or the app) and (b) create the first org +\n// instance-admin so the instance is usable at all. Both are owner/superuser-run,\n// day-one operations. `bootstrap` refuses to run against a non-empty instance, so\n// it is safe to leave wired into a deploy step.\n//\n// Composes the invariant-bearing helpers (password hashing/policy, membership\n// write-sync, audit) rather than re-hand-rolling them — only the org+admin insert\n// is direct, because at first run there is no prior actor and the whole thing must\n// be one transaction.\n\nimport { randomUUID } from 'node:crypto'\nimport postgres from 'postgres'\nimport { organizations, users, type GovcoreDb } from '@govcore/schema'\nimport { slugify, upsertMembership } from '@govcore/tenancy'\nimport { hashPassword, validatePassword, type PasswordPolicy } from '@govcore/auth'\nimport { writeAuditLog } from '@govcore/audit'\nimport { assertSafeIdentifier } from './identifier'\n\nexport { assertSafeIdentifier } from './identifier'\n\nexport interface ProvisionRuntimeRoleOptions {\n /** Owner/superuser connection string — role creation and GRANT are DDL. */\n connectionString: string\n /** The non-owner runtime role to create (RLS binds it). */\n role: string\n /** Login password for the role. */\n password: string\n /** Schemas to grant DML + default privileges on. Default `['govcore']`. */\n schemas?: string[]\n log?: (message: string) => void\n}\n\n/**\n * Create the non-owner runtime role (idempotent) and grant it DML on the given\n * schemas, including **default privileges** so tables created later (e.g. the\n * content engine's compiled tables) are reachable without re-granting. This is\n * the role the app connects as — it must NOT be a superuser, so RLS binds it\n * (see @govcore/auth's `authDb` for why login still needs a separate pool).\n *\n * Content-engine consumers pass `schemas: ['govcore', 'content']` once the\n * `content` schema exists.\n */\nexport async function provisionRuntimeRole(opts: ProvisionRuntimeRoleOptions): Promise<void> {\n assertSafeIdentifier(opts.role, 'role')\n const schemas = opts.schemas ?? ['govcore']\n schemas.forEach((s) => assertSafeIdentifier(s, 'schema'))\n const log = opts.log ?? (() => {})\n const escapedPassword = opts.password.replace(/'/g, \"''\")\n\n const sql = postgres(opts.connectionString, { max: 1 })\n try {\n await sql.unsafe(\n `DO $$ BEGIN CREATE ROLE ${opts.role} LOGIN PASSWORD '${escapedPassword}';\n EXCEPTION WHEN duplicate_object THEN NULL; END $$;`,\n )\n for (const schema of schemas) {\n await sql.unsafe(`GRANT USAGE ON SCHEMA ${schema} TO ${opts.role}`)\n await sql.unsafe(`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${schema} TO ${opts.role}`)\n await sql.unsafe(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${schema} TO ${opts.role}`)\n await sql.unsafe(\n `ALTER DEFAULT PRIVILEGES IN SCHEMA ${schema} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${opts.role}`,\n )\n await sql.unsafe(\n `ALTER DEFAULT PRIVILEGES IN SCHEMA ${schema} GRANT USAGE, SELECT ON SEQUENCES TO ${opts.role}`,\n )\n log(`govcore-setup: granted ${opts.role} on schema ${schema}`)\n }\n } finally {\n await sql.end()\n }\n}\n\nexport interface BootstrapOptions {\n organization: { name: string; slug?: string }\n admin: { email: string; name?: string; password: string }\n /** Org role for the first admin. Default `admin`. */\n adminRole?: string\n /** Password policy the initial password must satisfy. */\n policy?: PasswordPolicy\n}\n\nexport type BootstrapResult =\n | { ok: true; organizationId: string; adminUserId: string }\n | { ok: false; reason: 'already-bootstrapped' | 'missing-fields' | 'weak-password'; message?: string }\n\n/**\n * Create the first organization and its instance-admin on an **empty** instance.\n * Refuses (returns `already-bootstrapped`) if any organization already exists, so\n * it is safe to run repeatedly / leave in a deploy step. Runs as the owner —\n * inserting the first user predates any tenant GUC, which the runtime role's\n * FORCE-RLS would reject.\n *\n * The first admin is the actor of its own creation (self-bootstrap), audited as\n * `platform.bootstrap`. The password is never in the audit payload.\n */\nexport async function bootstrap(db: GovcoreDb, opts: BootstrapOptions): Promise<BootstrapResult> {\n const name = opts.organization.name.trim()\n const email = opts.admin.email.trim().toLowerCase()\n if (!name || !email || !opts.admin.password) {\n return { ok: false, reason: 'missing-fields' }\n }\n const validation = validatePassword(opts.admin.password, opts.policy)\n if (!validation.valid) return { ok: false, reason: 'weak-password', message: validation.message }\n\n const [existing] = await db.select({ id: organizations.id }).from(organizations).limit(1)\n if (existing) return { ok: false, reason: 'already-bootstrapped' }\n\n const adminRole = opts.adminRole ?? 'admin'\n const slug = opts.organization.slug?.trim() || slugify(name)\n const passwordHash = await hashPassword(opts.admin.password)\n const adminUserId = randomUUID()\n\n const result = await db.transaction(async (tx) => {\n const [org] = await tx.insert(organizations).values({ name, slug }).returning()\n await tx\n .insert(users)\n .values({\n id: adminUserId,\n organizationId: org.id,\n email,\n name: opts.admin.name?.trim() || null,\n passwordHash,\n role: adminRole,\n instanceRole: 'instance_admin',\n })\n await upsertMembership(tx, {\n userId: adminUserId,\n organizationId: org.id,\n role: adminRole,\n isPrimary: true,\n })\n await writeAuditLog(tx, {\n action: 'platform.bootstrap',\n entityType: 'organization',\n entityId: org.id,\n organizationId: org.id,\n userId: adminUserId,\n after: { organization: name, slug, admin: email, instanceRole: 'instance_admin' },\n })\n return { organizationId: org.id, adminUserId }\n })\n return { ok: true, ...result }\n}\n","// Pure SQL-identifier validation, kept in its own module (no heavy imports) so\n// it is unit-testable without loading the DB/auth stack.\n\n/** SQL identifiers (role, schema) are interpolated into DDL, so validate them. */\nconst IDENTIFIER_RE = /^[a-z_][a-z0-9_]*$/i\n\n/** Throw unless `name` is a safe SQL identifier (letters/digits/underscore, not digit-leading). */\nexport function assertSafeIdentifier(name: string, kind: string): void {\n if (!IDENTIFIER_RE.test(name)) {\n throw new Error(`Unsafe ${kind} identifier ${JSON.stringify(name)} — expected /^[A-Za-z_][A-Za-z0-9_]*$/`)\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { BootstrapResult } from './index.js';
|
|
2
|
+
import '@govcore/schema';
|
|
3
|
+
import '@govcore/auth';
|
|
4
|
+
|
|
5
|
+
interface RunSetupOptions {
|
|
6
|
+
/** Owner/superuser connection string. All three steps are owner operations. */
|
|
7
|
+
connectionString: string;
|
|
8
|
+
/** Optional: provision a non-owner runtime role + grants (the two-role split). */
|
|
9
|
+
runtimeRole?: {
|
|
10
|
+
role: string;
|
|
11
|
+
password: string;
|
|
12
|
+
schemas?: string[];
|
|
13
|
+
};
|
|
14
|
+
/** The first organization + instance-admin to create (skipped if already bootstrapped). */
|
|
15
|
+
organization: {
|
|
16
|
+
name: string;
|
|
17
|
+
slug?: string;
|
|
18
|
+
};
|
|
19
|
+
admin: {
|
|
20
|
+
email: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
password: string;
|
|
23
|
+
};
|
|
24
|
+
adminRole?: string;
|
|
25
|
+
log?: (message: string) => void;
|
|
26
|
+
}
|
|
27
|
+
interface RunSetupResult {
|
|
28
|
+
migrationsApplied: string[];
|
|
29
|
+
runtimeRoleProvisioned: boolean;
|
|
30
|
+
bootstrap: BootstrapResult;
|
|
31
|
+
}
|
|
32
|
+
/** Migrate → (optional) provision runtime role → bootstrap. Idempotent end to end. */
|
|
33
|
+
declare function runSetup(opts: RunSetupOptions): Promise<RunSetupResult>;
|
|
34
|
+
|
|
35
|
+
export { type RunSetupOptions, type RunSetupResult, runSetup };
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
bootstrap,
|
|
3
|
+
provisionRuntimeRole
|
|
4
|
+
} from "./chunk-PZIHAYWT.js";
|
|
5
|
+
|
|
6
|
+
// src/cli.ts
|
|
7
|
+
import postgres from "postgres";
|
|
8
|
+
import { drizzle } from "drizzle-orm/postgres-js";
|
|
9
|
+
import { migrate } from "@govcore/schema/migrate";
|
|
10
|
+
async function runSetup(opts) {
|
|
11
|
+
const log = opts.log ?? ((m) => console.log(m));
|
|
12
|
+
const { applied } = await migrate({ connectionString: opts.connectionString, log });
|
|
13
|
+
let runtimeRoleProvisioned = false;
|
|
14
|
+
if (opts.runtimeRole) {
|
|
15
|
+
await provisionRuntimeRole({
|
|
16
|
+
connectionString: opts.connectionString,
|
|
17
|
+
role: opts.runtimeRole.role,
|
|
18
|
+
password: opts.runtimeRole.password,
|
|
19
|
+
schemas: opts.runtimeRole.schemas,
|
|
20
|
+
log
|
|
21
|
+
});
|
|
22
|
+
runtimeRoleProvisioned = true;
|
|
23
|
+
}
|
|
24
|
+
const client = postgres(opts.connectionString, { max: 1 });
|
|
25
|
+
try {
|
|
26
|
+
const result = await bootstrap(drizzle(client), {
|
|
27
|
+
organization: opts.organization,
|
|
28
|
+
admin: opts.admin,
|
|
29
|
+
adminRole: opts.adminRole
|
|
30
|
+
});
|
|
31
|
+
if (result.ok) log(`govcore-setup: bootstrapped org ${result.organizationId}`);
|
|
32
|
+
else log(`govcore-setup: bootstrap skipped (${result.reason})`);
|
|
33
|
+
return { migrationsApplied: applied, runtimeRoleProvisioned, bootstrap: result };
|
|
34
|
+
} finally {
|
|
35
|
+
await client.end();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function optionsFromEnv() {
|
|
39
|
+
const env = process.env;
|
|
40
|
+
const connectionString = env.GOVCORE_SETUP_DATABASE_URL || env.DATABASE_URL;
|
|
41
|
+
const require_ = (key) => {
|
|
42
|
+
const v = env[key];
|
|
43
|
+
if (!v) {
|
|
44
|
+
console.error(`govcore-setup: missing required env var ${key}`);
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
return v;
|
|
48
|
+
};
|
|
49
|
+
if (!connectionString) {
|
|
50
|
+
console.error("govcore-setup: set GOVCORE_SETUP_DATABASE_URL or DATABASE_URL (owner/superuser)");
|
|
51
|
+
process.exit(2);
|
|
52
|
+
}
|
|
53
|
+
const runtimeRole = env.GOVCORE_APP_ROLE ? {
|
|
54
|
+
role: env.GOVCORE_APP_ROLE,
|
|
55
|
+
password: require_("GOVCORE_APP_PASSWORD"),
|
|
56
|
+
schemas: env.GOVCORE_APP_SCHEMAS?.split(",").map((s) => s.trim())
|
|
57
|
+
} : void 0;
|
|
58
|
+
return {
|
|
59
|
+
connectionString,
|
|
60
|
+
runtimeRole,
|
|
61
|
+
organization: { name: require_("GOVCORE_ORG_NAME"), slug: env.GOVCORE_ORG_SLUG },
|
|
62
|
+
admin: {
|
|
63
|
+
email: require_("GOVCORE_ADMIN_EMAIL"),
|
|
64
|
+
name: env.GOVCORE_ADMIN_NAME,
|
|
65
|
+
password: require_("GOVCORE_ADMIN_PASSWORD")
|
|
66
|
+
},
|
|
67
|
+
adminRole: env.GOVCORE_ADMIN_ROLE
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
|
71
|
+
runSetup(optionsFromEnv()).then((r) => {
|
|
72
|
+
if (!r.bootstrap.ok && r.bootstrap.reason !== "already-bootstrapped") {
|
|
73
|
+
console.error(`govcore-setup: bootstrap failed (${r.bootstrap.reason})`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}).catch((err) => {
|
|
77
|
+
console.error(err);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export {
|
|
82
|
+
runSetup
|
|
83
|
+
};
|
|
84
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli.ts"],"sourcesContent":["// @govcore/setup/cli — the govcore-setup runner + bin.\n//\n// One idempotent command from an empty database to first login: migrate →\n// provision the runtime role → bootstrap the first org/admin. Runs as the\n// OWNER/superuser (all three steps are owner operations). Mirrors govcore-migrate's\n// bin pattern (no shebang; guarded by the import.meta.url check).\n\nimport postgres from 'postgres'\nimport { drizzle } from 'drizzle-orm/postgres-js'\nimport { migrate } from '@govcore/schema/migrate'\nimport { bootstrap, provisionRuntimeRole, type BootstrapResult } from './index'\n\nexport interface RunSetupOptions {\n /** Owner/superuser connection string. All three steps are owner operations. */\n connectionString: string\n /** Optional: provision a non-owner runtime role + grants (the two-role split). */\n runtimeRole?: { role: string; password: string; schemas?: string[] }\n /** The first organization + instance-admin to create (skipped if already bootstrapped). */\n organization: { name: string; slug?: string }\n admin: { email: string; name?: string; password: string }\n adminRole?: string\n log?: (message: string) => void\n}\n\nexport interface RunSetupResult {\n migrationsApplied: string[]\n runtimeRoleProvisioned: boolean\n bootstrap: BootstrapResult\n}\n\n/** Migrate → (optional) provision runtime role → bootstrap. Idempotent end to end. */\nexport async function runSetup(opts: RunSetupOptions): Promise<RunSetupResult> {\n const log = opts.log ?? ((m: string) => console.log(m))\n\n const { applied } = await migrate({ connectionString: opts.connectionString, log })\n\n let runtimeRoleProvisioned = false\n if (opts.runtimeRole) {\n await provisionRuntimeRole({\n connectionString: opts.connectionString,\n role: opts.runtimeRole.role,\n password: opts.runtimeRole.password,\n schemas: opts.runtimeRole.schemas,\n log,\n })\n runtimeRoleProvisioned = true\n }\n\n const client = postgres(opts.connectionString, { max: 1 })\n try {\n const result = await bootstrap(drizzle(client), {\n organization: opts.organization,\n admin: opts.admin,\n adminRole: opts.adminRole,\n })\n if (result.ok) log(`govcore-setup: bootstrapped org ${result.organizationId}`)\n else log(`govcore-setup: bootstrap skipped (${result.reason})`)\n return { migrationsApplied: applied, runtimeRoleProvisioned, bootstrap: result }\n } finally {\n await client.end()\n }\n}\n\n/** Read the runner options from environment variables (for the bin). */\nfunction optionsFromEnv(): RunSetupOptions {\n const env = process.env\n const connectionString = env.GOVCORE_SETUP_DATABASE_URL || env.DATABASE_URL\n const require_ = (key: string): string => {\n const v = env[key]\n if (!v) {\n console.error(`govcore-setup: missing required env var ${key}`)\n process.exit(2)\n }\n return v\n }\n if (!connectionString) {\n console.error('govcore-setup: set GOVCORE_SETUP_DATABASE_URL or DATABASE_URL (owner/superuser)')\n process.exit(2)\n }\n const runtimeRole = env.GOVCORE_APP_ROLE\n ? {\n role: env.GOVCORE_APP_ROLE,\n password: require_('GOVCORE_APP_PASSWORD'),\n schemas: env.GOVCORE_APP_SCHEMAS?.split(',').map((s) => s.trim()),\n }\n : undefined\n return {\n connectionString,\n runtimeRole,\n organization: { name: require_('GOVCORE_ORG_NAME'), slug: env.GOVCORE_ORG_SLUG },\n admin: {\n email: require_('GOVCORE_ADMIN_EMAIL'),\n name: env.GOVCORE_ADMIN_NAME,\n password: require_('GOVCORE_ADMIN_PASSWORD'),\n },\n adminRole: env.GOVCORE_ADMIN_ROLE,\n }\n}\n\n// Run when invoked as the `govcore-setup` bin.\nif (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {\n runSetup(optionsFromEnv())\n .then((r) => {\n if (!r.bootstrap.ok && r.bootstrap.reason !== 'already-bootstrapped') {\n console.error(`govcore-setup: bootstrap failed (${r.bootstrap.reason})`)\n process.exit(1)\n }\n })\n .catch((err) => {\n console.error(err)\n process.exit(1)\n })\n}\n"],"mappings":";;;;;;AAOA,OAAO,cAAc;AACrB,SAAS,eAAe;AACxB,SAAS,eAAe;AAsBxB,eAAsB,SAAS,MAAgD;AAC7E,QAAM,MAAM,KAAK,QAAQ,CAAC,MAAc,QAAQ,IAAI,CAAC;AAErD,QAAM,EAAE,QAAQ,IAAI,MAAM,QAAQ,EAAE,kBAAkB,KAAK,kBAAkB,IAAI,CAAC;AAElF,MAAI,yBAAyB;AAC7B,MAAI,KAAK,aAAa;AACpB,UAAM,qBAAqB;AAAA,MACzB,kBAAkB,KAAK;AAAA,MACvB,MAAM,KAAK,YAAY;AAAA,MACvB,UAAU,KAAK,YAAY;AAAA,MAC3B,SAAS,KAAK,YAAY;AAAA,MAC1B;AAAA,IACF,CAAC;AACD,6BAAyB;AAAA,EAC3B;AAEA,QAAM,SAAS,SAAS,KAAK,kBAAkB,EAAE,KAAK,EAAE,CAAC;AACzD,MAAI;AACF,UAAM,SAAS,MAAM,UAAU,QAAQ,MAAM,GAAG;AAAA,MAC9C,cAAc,KAAK;AAAA,MACnB,OAAO,KAAK;AAAA,MACZ,WAAW,KAAK;AAAA,IAClB,CAAC;AACD,QAAI,OAAO,GAAI,KAAI,mCAAmC,OAAO,cAAc,EAAE;AAAA,QACxE,KAAI,qCAAqC,OAAO,MAAM,GAAG;AAC9D,WAAO,EAAE,mBAAmB,SAAS,wBAAwB,WAAW,OAAO;AAAA,EACjF,UAAE;AACA,UAAM,OAAO,IAAI;AAAA,EACnB;AACF;AAGA,SAAS,iBAAkC;AACzC,QAAM,MAAM,QAAQ;AACpB,QAAM,mBAAmB,IAAI,8BAA8B,IAAI;AAC/D,QAAM,WAAW,CAAC,QAAwB;AACxC,UAAM,IAAI,IAAI,GAAG;AACjB,QAAI,CAAC,GAAG;AACN,cAAQ,MAAM,2CAA2C,GAAG,EAAE;AAC9D,cAAQ,KAAK,CAAC;AAAA,IAChB;AACA,WAAO;AAAA,EACT;AACA,MAAI,CAAC,kBAAkB;AACrB,YAAQ,MAAM,iFAAiF;AAC/F,YAAQ,KAAK,CAAC;AAAA,EAChB;AACA,QAAM,cAAc,IAAI,mBACpB;AAAA,IACE,MAAM,IAAI;AAAA,IACV,UAAU,SAAS,sBAAsB;AAAA,IACzC,SAAS,IAAI,qBAAqB,MAAM,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC;AAAA,EAClE,IACA;AACJ,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,cAAc,EAAE,MAAM,SAAS,kBAAkB,GAAG,MAAM,IAAI,iBAAiB;AAAA,IAC/E,OAAO;AAAA,MACL,OAAO,SAAS,qBAAqB;AAAA,MACrC,MAAM,IAAI;AAAA,MACV,UAAU,SAAS,wBAAwB;AAAA,IAC7C;AAAA,IACA,WAAW,IAAI;AAAA,EACjB;AACF;AAGA,IAAI,QAAQ,KAAK,CAAC,KAAK,YAAY,QAAQ,UAAU,QAAQ,KAAK,CAAC,CAAC,IAAI;AACtE,WAAS,eAAe,CAAC,EACtB,KAAK,CAAC,MAAM;AACX,QAAI,CAAC,EAAE,UAAU,MAAM,EAAE,UAAU,WAAW,wBAAwB;AACpE,cAAQ,MAAM,oCAAoC,EAAE,UAAU,MAAM,GAAG;AACvE,cAAQ,KAAK,CAAC;AAAA,IAChB;AAAA,EACF,CAAC,EACA,MAAM,CAAC,QAAQ;AACd,YAAQ,MAAM,GAAG;AACjB,YAAQ,KAAK,CAAC;AAAA,EAChB,CAAC;AACL;","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { GovcoreDb } from '@govcore/schema';
|
|
2
|
+
import { PasswordPolicy } from '@govcore/auth';
|
|
3
|
+
|
|
4
|
+
/** Throw unless `name` is a safe SQL identifier (letters/digits/underscore, not digit-leading). */
|
|
5
|
+
declare function assertSafeIdentifier(name: string, kind: string): void;
|
|
6
|
+
|
|
7
|
+
interface ProvisionRuntimeRoleOptions {
|
|
8
|
+
/** Owner/superuser connection string — role creation and GRANT are DDL. */
|
|
9
|
+
connectionString: string;
|
|
10
|
+
/** The non-owner runtime role to create (RLS binds it). */
|
|
11
|
+
role: string;
|
|
12
|
+
/** Login password for the role. */
|
|
13
|
+
password: string;
|
|
14
|
+
/** Schemas to grant DML + default privileges on. Default `['govcore']`. */
|
|
15
|
+
schemas?: string[];
|
|
16
|
+
log?: (message: string) => void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create the non-owner runtime role (idempotent) and grant it DML on the given
|
|
20
|
+
* schemas, including **default privileges** so tables created later (e.g. the
|
|
21
|
+
* content engine's compiled tables) are reachable without re-granting. This is
|
|
22
|
+
* the role the app connects as — it must NOT be a superuser, so RLS binds it
|
|
23
|
+
* (see @govcore/auth's `authDb` for why login still needs a separate pool).
|
|
24
|
+
*
|
|
25
|
+
* Content-engine consumers pass `schemas: ['govcore', 'content']` once the
|
|
26
|
+
* `content` schema exists.
|
|
27
|
+
*/
|
|
28
|
+
declare function provisionRuntimeRole(opts: ProvisionRuntimeRoleOptions): Promise<void>;
|
|
29
|
+
interface BootstrapOptions {
|
|
30
|
+
organization: {
|
|
31
|
+
name: string;
|
|
32
|
+
slug?: string;
|
|
33
|
+
};
|
|
34
|
+
admin: {
|
|
35
|
+
email: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
password: string;
|
|
38
|
+
};
|
|
39
|
+
/** Org role for the first admin. Default `admin`. */
|
|
40
|
+
adminRole?: string;
|
|
41
|
+
/** Password policy the initial password must satisfy. */
|
|
42
|
+
policy?: PasswordPolicy;
|
|
43
|
+
}
|
|
44
|
+
type BootstrapResult = {
|
|
45
|
+
ok: true;
|
|
46
|
+
organizationId: string;
|
|
47
|
+
adminUserId: string;
|
|
48
|
+
} | {
|
|
49
|
+
ok: false;
|
|
50
|
+
reason: 'already-bootstrapped' | 'missing-fields' | 'weak-password';
|
|
51
|
+
message?: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Create the first organization and its instance-admin on an **empty** instance.
|
|
55
|
+
* Refuses (returns `already-bootstrapped`) if any organization already exists, so
|
|
56
|
+
* it is safe to run repeatedly / leave in a deploy step. Runs as the owner —
|
|
57
|
+
* inserting the first user predates any tenant GUC, which the runtime role's
|
|
58
|
+
* FORCE-RLS would reject.
|
|
59
|
+
*
|
|
60
|
+
* The first admin is the actor of its own creation (self-bootstrap), audited as
|
|
61
|
+
* `platform.bootstrap`. The password is never in the audit payload.
|
|
62
|
+
*/
|
|
63
|
+
declare function bootstrap(db: GovcoreDb, opts: BootstrapOptions): Promise<BootstrapResult>;
|
|
64
|
+
|
|
65
|
+
export { type BootstrapOptions, type BootstrapResult, type ProvisionRuntimeRoleOptions, assertSafeIdentifier, bootstrap, provisionRuntimeRole };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@govcore/setup",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "First-run bootstrap: runtime-role provisioning + first org/admin, idempotent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./cli": {
|
|
15
|
+
"types": "./dist/cli.d.ts",
|
|
16
|
+
"default": "./dist/cli.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"govcore-setup": "./dist/cli.js"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"drizzle-orm": "^0.45.2",
|
|
27
|
+
"postgres": "^3.4.0",
|
|
28
|
+
"@govcore/auth": "0.4.0",
|
|
29
|
+
"@govcore/schema": "0.3.0",
|
|
30
|
+
"@govcore/audit": "0.1.3",
|
|
31
|
+
"@govcore/tenancy": "0.3.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"typescript": "^5.5.0",
|
|
35
|
+
"tsup": "^8.3.0"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist"
|
|
39
|
+
],
|
|
40
|
+
"tsup": {
|
|
41
|
+
"entry": [
|
|
42
|
+
"src/index.ts",
|
|
43
|
+
"src/cli.ts"
|
|
44
|
+
],
|
|
45
|
+
"format": [
|
|
46
|
+
"esm"
|
|
47
|
+
],
|
|
48
|
+
"dts": true,
|
|
49
|
+
"clean": true,
|
|
50
|
+
"sourcemap": true
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsup",
|
|
54
|
+
"dev": "tsup --watch"
|
|
55
|
+
}
|
|
56
|
+
}
|