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.
122 lines
4.8 KiB
TypeScript
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 });
|
|
}
|