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.
This commit is contained in:
ekklesia
2026-07-23 11:16:32 -05:00
parent 72e082ff2f
commit 1363608552
33 changed files with 2962 additions and 63 deletions
+1
View File
@@ -0,0 +1 @@
export * from "./pool.js";
+110
View File
@@ -0,0 +1,110 @@
import { Pool, type PoolConfig } from "pg";
export type PostgresPoolErrorCode = "POSTGRES_POOL_CONFIGURATION_INVALID";
/** Never carries the offending connection string, host, or underlying driver error. */
export class PostgresPoolError extends Error {
readonly code: PostgresPoolErrorCode;
constructor(code: PostgresPoolErrorCode) {
super("PostgreSQL pool configuration is invalid.");
this.name = "PostgresPoolError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
const maximumPostgresTimeoutMs = 2_147_483_647;
export interface PostgresPoolOptions {
readonly connectionTimeoutMs?: number;
/** Bounds both `query_timeout` and `statement_timeout`: `connectionTimeoutMs` alone only bounds
* establishing a connection, not a hung query already running on one. */
readonly operationTimeoutMs?: number;
}
export interface PostgresPoolHandle {
readonly close: () => Promise<void>;
readonly pool: Pool;
}
function validateConnectionString(connectionString: string): string {
if (connectionString.trim().length === 0) {
throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID");
}
let protocol: string;
try {
protocol = new URL(connectionString).protocol;
} catch {
throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID");
}
if (protocol !== "postgres:" && protocol !== "postgresql:") {
throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID");
}
return connectionString;
}
/** Mirrors the validation already used by `core/src/identity/database.ts`,
* `core/src/authorization/database.ts`, and `core/src/migrations/postgres-database.ts`: 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 PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID");
}
return value;
}
/**
* Ekklesia's one shared, validated `pg.Pool` constructor for a single physical database
* connection meant to back more than one typed Drizzle view (for example Core's identity and
* authorization schemas sharing one pool in `apps/api`) — one pool, one mandatory error listener,
* one `close()`. Building the 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.
*
* This does not replace `createIdentityDrizzleDatabase` or `createAuthorizationDrizzleDatabase`:
* those remain independent, self-contained factories (each owns a private pool and its own
* `close()`) for callers — tests included — that only need one domain. Use this function, plus
* `createIdentityDrizzleView`/`createAuthorizationDrizzleView`, specifically when more than one
* domain must share one physical connection pool.
*/
export function createValidatedPostgresPool(
connectionString: string,
options: PostgresPoolOptions = {},
): PostgresPoolHandle {
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);
return Object.freeze({
close: () => pool.end(),
pool,
});
}