feat: add core authorization foundation

This commit is contained in:
ekklesia
2026-07-23 10:29:01 -05:00
parent 3fa1c4cdec
commit 72e082ff2f
23 changed files with 3029 additions and 3 deletions
+107
View File
@@ -0,0 +1,107 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import { Pool, type PoolConfig } from "pg";
import { authorizationSchema } from "./schema.js";
export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>;
export type AuthorizationDatabaseErrorCode = "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID";
const maximumPostgresTimeoutMs = 2_147_483_647;
/** Never carries the offending connection string, host, or underlying driver error. */
export class AuthorizationDatabaseError extends Error {
readonly code: AuthorizationDatabaseErrorCode;
constructor(code: AuthorizationDatabaseErrorCode) {
super("Authorization database configuration is invalid.");
this.name = "AuthorizationDatabaseError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface AuthorizationDatabaseOptions {
readonly connectionTimeoutMs?: number;
/** Bounds both `query_timeout` and `statement_timeout`, mirroring
* `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a
* connection, not a hung query already running on one. */
readonly operationTimeoutMs?: number;
}
export interface AuthorizationDatabaseHandle {
readonly close: () => Promise<void>;
readonly db: AuthorizationDrizzleDatabase;
}
function validateConnectionString(connectionString: string): string {
if (connectionString.trim().length === 0) {
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
}
let protocol: string;
try {
protocol = new URL(connectionString).protocol;
} catch {
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
}
if (protocol !== "postgres:" && protocol !== "postgresql:") {
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
}
return connectionString;
}
/** Mirrors `core/src/identity/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 AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
}
return value;
}
/**
* Explicitly constructs the Core-owned Drizzle database instance backing the authorization store.
* 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 createAuthorizationDrizzleDatabase(
connectionString: string,
options: AuthorizationDatabaseOptions = {},
): AuthorizationDatabaseHandle {
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);
// Same rationale as core/src/identity/database.ts: pg's own documentation requires an error
// listener on the pool, or a server-initiated termination of an idle client becomes an
// unhandled Node.js error and can crash the process.
pool.on("error", () => undefined);
const db = drizzle(pool, { schema: authorizationSchema });
return Object.freeze({
close: () => pool.end(),
db,
});
}