Files
Ekklesia/core/src/identity/database.ts
T
ekklesia 1363608552 feat: add authorization bootstrap and HTTP enforcement
Wires Core's identity and authorization domains into apps/api over one
shared, validated PostgreSQL pool; adds an explicit, single-use,
concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible
immutable permission registry with one Core-owned restricted permission
(core.authorization.grants.manage); and adds reusable Hono authorization
enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing
explicit grants only, gated by an exact trusted Origin on every mutation.
2026-07-23 11:16:32 -05:00

122 lines
4.8 KiB
TypeScript

import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import { Pool, type PoolConfig } from "pg";
import { identityAdapterSchema } from "./schema.js";
export type IdentityDrizzleDatabase = NodePgDatabase<typeof identityAdapterSchema>;
export type IdentityDatabaseErrorCode = "IDENTITY_DATABASE_CONFIGURATION_INVALID";
const maximumPostgresTimeoutMs = 2_147_483_647;
/** Never carries the offending connection string, host, or underlying driver error. */
export class IdentityDatabaseError extends Error {
readonly code: IdentityDatabaseErrorCode;
constructor(code: IdentityDatabaseErrorCode) {
super("Identity database configuration is invalid.");
this.name = "IdentityDatabaseError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface IdentityDatabaseOptions {
readonly connectionTimeoutMs?: number;
/** Bounds both `query_timeout` and `statement_timeout`: a hung or unexpectedly slow query on
* the identity connection is aborted rather than holding the pool connection (and the caller)
* indefinitely. `connectionTimeoutMs` alone only bounds establishing a connection — it does
* nothing once a query is already running on one. */
readonly operationTimeoutMs?: number;
}
export interface IdentityDatabaseHandle {
readonly close: () => Promise<void>;
readonly db: IdentityDrizzleDatabase;
}
function validateConnectionString(connectionString: string): string {
if (connectionString.trim().length === 0) {
throw new IdentityDatabaseError("IDENTITY_DATABASE_CONFIGURATION_INVALID");
}
let protocol: string;
try {
protocol = new URL(connectionString).protocol;
} catch {
throw new IdentityDatabaseError("IDENTITY_DATABASE_CONFIGURATION_INVALID");
}
if (protocol !== "postgres:" && protocol !== "postgresql:") {
throw new IdentityDatabaseError("IDENTITY_DATABASE_CONFIGURATION_INVALID");
}
return connectionString;
}
/** Mirrors `core/src/migrations/postgres-database.ts`'s timeout validation exactly: an explicit
* timeout must be a positive safe integer no larger than PostgreSQL's own maximum, or it is
* rejected as invalid configuration rather than silently passed through to `pg`. */
function validateTimeout(value: unknown): number | undefined {
if (value === undefined) {
return undefined;
}
if (
typeof value !== "number" ||
!Number.isSafeInteger(value) ||
value <= 0 ||
value > maximumPostgresTimeoutMs
) {
throw new IdentityDatabaseError("IDENTITY_DATABASE_CONFIGURATION_INVALID");
}
return value;
}
/**
* Explicitly constructs the Core-owned Drizzle database instance used by the identity factory.
* Building the `pg.Pool` performs no network I/O until a query is issued, and this function is
* only ever invoked by explicit caller code — never during module import.
*/
export function createIdentityDrizzleDatabase(
connectionString: string,
options: IdentityDatabaseOptions = {},
): IdentityDatabaseHandle {
const validatedConnectionString = validateConnectionString(connectionString);
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
const poolConfig: PoolConfig = {
connectionString: validatedConnectionString,
...(validatedConnectionTimeoutMs === undefined
? {}
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
...(validatedOperationTimeoutMs === undefined
? {}
: {
query_timeout: validatedOperationTimeoutMs,
statement_timeout: validatedOperationTimeoutMs,
}),
};
const pool = new Pool(poolConfig);
// pg's own documentation requires an error listener on the pool: an idle client can be
// terminated by the server (for example, its backend is killed, or the network drops) after
// being returned to the pool, and the pool surfaces that as an 'error' event on itself rather
// than on any in-flight query. Without a listener, that event is an unhandled Node.js error and
// can crash the process even though no caller is doing anything wrong at the time.
pool.on("error", () => undefined);
const db = drizzle(pool, { schema: identityAdapterSchema });
return Object.freeze({
close: () => pool.end(),
db,
});
}
/**
* A typed Drizzle view over an externally owned pool — for example one shared with the
* authorization schema via `createValidatedPostgresPool` (`core/src/database/pool.ts`). Unlike
* `createIdentityDrizzleDatabase`, this function never constructs, registers an error listener
* on, or closes a pool itself: the caller retains that responsibility exactly once, regardless of
* how many typed views it creates over the same pool.
*/
export function createIdentityDrizzleView(pool: Pool): IdentityDrizzleDatabase {
return drizzle(pool, { schema: identityAdapterSchema });
}