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:
@@ -30,6 +30,10 @@
|
||||
"./authorization": {
|
||||
"types": "./dist/authorization/index.d.ts",
|
||||
"import": "./dist/authorization/index.js"
|
||||
},
|
||||
"./database": {
|
||||
"types": "./dist/database/index.d.ts",
|
||||
"import": "./dist/database/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||
import { ekklesiaCoreAuthorizationAudit, ekklesiaCorePlatformOwner } from "./schema.js";
|
||||
|
||||
export type PlatformOwnerBootstrapErrorCode =
|
||||
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
||||
| "PLATFORM_OWNER_BOOTSTRAP_FAILED"
|
||||
| "PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND";
|
||||
|
||||
const bootstrapErrorMessages: Readonly<Record<PlatformOwnerBootstrapErrorCode, string>> =
|
||||
Object.freeze({
|
||||
PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED:
|
||||
"Platform-owner bootstrap has already been used and cannot be used again.",
|
||||
PLATFORM_OWNER_BOOTSTRAP_FAILED: "Platform-owner bootstrap could not be completed.",
|
||||
PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND: "The target user does not exist.",
|
||||
});
|
||||
|
||||
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
|
||||
export class PlatformOwnerBootstrapError extends Error {
|
||||
readonly code: PlatformOwnerBootstrapErrorCode;
|
||||
|
||||
constructor(code: PlatformOwnerBootstrapErrorCode) {
|
||||
super(bootstrapErrorMessages[code]);
|
||||
this.name = "PlatformOwnerBootstrapError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlatformOwnerBootstrapError(error: unknown): error is PlatformOwnerBootstrapError {
|
||||
return error instanceof PlatformOwnerBootstrapError;
|
||||
}
|
||||
|
||||
/** A fixed, sanitized, non-user actor identity for the audit trail — distinguishable from both an
|
||||
* ordinary "system" action with no specific actor and from any real `EkklesiaUserId`, so an
|
||||
* auditor can tell this owner grant came specifically from the bootstrap command. Never a real
|
||||
* user id and never derived from caller input. */
|
||||
const BOOTSTRAP_ACTOR_TYPE = "system";
|
||||
const BOOTSTRAP_ACTOR_ID = "authorization-bootstrap-cli";
|
||||
|
||||
// A dedicated advisory-lock key pair, distinct from `core/src/migrations/postgres-database.ts`'s
|
||||
// migration lock, so bootstrap serialization can never collide with or be blocked by unrelated
|
||||
// migration locking. `pg_advisory_xact_lock` is transaction-scoped: it is released automatically
|
||||
// on COMMIT or ROLLBACK, so no manual unlock is needed even if the transaction fails partway.
|
||||
const bootstrapLockNamespace = 1_500_000_001;
|
||||
const bootstrapLockKey = 1_500_000_002;
|
||||
|
||||
export interface BootstrapPlatformOwnerInput {
|
||||
readonly userId: EkklesiaUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a
|
||||
* composition of "check, then call the existing grant function": the single-use check, the target
|
||||
* user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL
|
||||
* transaction, serialized by a transaction-scoped advisory lock acquired as the very first
|
||||
* statement — so two concurrent callers can never both observe "no owner yet" and both insert.
|
||||
* The second caller blocks on the lock until the first's transaction commits or rolls back, then
|
||||
* re-evaluates against the now-current state.
|
||||
*
|
||||
* "Already consumed" is global and permanent: it is true the moment
|
||||
* `ekklesia_core_platform_owner` has ever held any row for any user, including one whose
|
||||
* `revoked_at` is set — a later, separate owner-management flow (not part of this slice) is how a
|
||||
* platform owner would ever be re-established after a revocation, not this command.
|
||||
*/
|
||||
export async function bootstrapPlatformOwner(
|
||||
db: AuthorizationDrizzleDatabase,
|
||||
input: BootstrapPlatformOwnerInput,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
|
||||
);
|
||||
|
||||
const existingOwnerRows = await tx
|
||||
.select({ userId: ekklesiaCorePlatformOwner.userId })
|
||||
.from(ekklesiaCorePlatformOwner)
|
||||
.limit(1);
|
||||
if (existingOwnerRows.length > 0) {
|
||||
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED");
|
||||
}
|
||||
|
||||
const targetUserRows = await tx
|
||||
.select({ id: ekklesiaCoreUser.id })
|
||||
.from(ekklesiaCoreUser)
|
||||
.where(eq(ekklesiaCoreUser.id, input.userId))
|
||||
.limit(1);
|
||||
if (targetUserRows.length === 0) {
|
||||
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND");
|
||||
}
|
||||
|
||||
await tx.insert(ekklesiaCorePlatformOwner).values({
|
||||
grantedByActorId: BOOTSTRAP_ACTOR_ID,
|
||||
grantedByActorType: BOOTSTRAP_ACTOR_TYPE,
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||
action: "platform_owner.grant",
|
||||
actorId: BOOTSTRAP_ACTOR_ID,
|
||||
actorType: BOOTSTRAP_ACTOR_TYPE,
|
||||
subjectUserId: input.userId,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (isPlatformOwnerBootstrapError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_FAILED");
|
||||
}
|
||||
}
|
||||
@@ -105,3 +105,14 @@ export function createAuthorizationDrizzleDatabase(
|
||||
db,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed Drizzle view over an externally owned pool — for example one shared with the identity
|
||||
* schema via `createValidatedPostgresPool` (`core/src/database/pool.ts`). Unlike
|
||||
* `createAuthorizationDrizzleDatabase`, 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 createAuthorizationDrizzleView(pool: Pool): AuthorizationDrizzleDatabase {
|
||||
return drizzle(pool, { schema: authorizationSchema });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./bootstrap.js";
|
||||
export * from "./database.js";
|
||||
export * from "./evaluator.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./ports.js";
|
||||
export * from "./store.js";
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
type PermissionBaseline,
|
||||
type PermissionId,
|
||||
parsePermissionId,
|
||||
safeParsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
/**
|
||||
* A registered permission declaration: the trusted pairing of a permission identifier with its
|
||||
* one baseline. This is what distinguishes a *registered* permission from a merely well-formed
|
||||
* `PermissionId` string — the evaluator itself (`createAuthorizationEvaluator`) still takes an
|
||||
* explicit, caller-supplied baseline per pass 1's design; the registry is what a trusted caller
|
||||
* (Core's own HTTP enforcement, eventually a module registration mechanism) consults so that
|
||||
* baseline is never client-supplied or guessed.
|
||||
*/
|
||||
export interface PermissionDeclaration {
|
||||
readonly id: PermissionId;
|
||||
readonly baseline: PermissionBaseline;
|
||||
/** Human-readable, for operators and audit review only — never interpreted by the evaluator or
|
||||
* by enforcement. */
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
export interface PermissionRegistry {
|
||||
readonly declarations: readonly PermissionDeclaration[];
|
||||
/** Returns the declaration for a permission id, or `undefined` for anything malformed or not
|
||||
* registered — the two are deliberately indistinguishable to callers, so "unknown permission"
|
||||
* and "invalid permission" fail closed the same way. */
|
||||
get(permissionId: unknown): PermissionDeclaration | undefined;
|
||||
has(permissionId: unknown): boolean;
|
||||
}
|
||||
|
||||
export type PermissionRegistryErrorCode = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION";
|
||||
|
||||
/** Thrown only at composition time (registry construction), never per-request. Carries the
|
||||
* offending permission id deliberately — this is a startup-time programmer/configuration error
|
||||
* message, not a runtime response, and a namespaced permission id is not sensitive. */
|
||||
export class PermissionRegistryError extends Error {
|
||||
readonly code: PermissionRegistryErrorCode;
|
||||
readonly permissionId: string;
|
||||
|
||||
constructor(permissionId: string) {
|
||||
super(`Permission "${permissionId}" is already registered.`);
|
||||
this.name = "PermissionRegistryError";
|
||||
this.code = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION";
|
||||
this.permissionId = permissionId;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an immutable permission registry from a fixed list of declarations, rejecting any
|
||||
* duplicate permission id outright at composition time. Deliberately the smallest mechanism that
|
||||
* distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no
|
||||
* network or filesystem I/O — a future module registration mechanism can merge its own
|
||||
* declarations into a combined list and still call this same constructor, inheriting duplicate
|
||||
* detection for free instead of reimplementing it.
|
||||
*/
|
||||
export function createPermissionRegistry(
|
||||
declarations: readonly PermissionDeclaration[],
|
||||
): PermissionRegistry {
|
||||
const byId = new Map<PermissionId, PermissionDeclaration>();
|
||||
for (const declaration of declarations) {
|
||||
if (byId.has(declaration.id)) {
|
||||
throw new PermissionRegistryError(declaration.id);
|
||||
}
|
||||
byId.set(declaration.id, Object.freeze({ ...declaration }));
|
||||
}
|
||||
const frozenDeclarations = Object.freeze([...byId.values()]);
|
||||
|
||||
return Object.freeze({
|
||||
declarations: frozenDeclarations,
|
||||
get(permissionId: unknown): PermissionDeclaration | undefined {
|
||||
const parsed = safeParsePermissionId(permissionId);
|
||||
return parsed.success ? byId.get(parsed.data) : undefined;
|
||||
},
|
||||
has(permissionId: unknown): boolean {
|
||||
const parsed = safeParsePermissionId(permissionId);
|
||||
return parsed.success && byId.has(parsed.data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Restricted: delegated grant administration is itself a privileged action, never public or
|
||||
* merely authenticated. A platform owner always passes the evaluator's existing owner bypass and
|
||||
* can delegate this permission to another account deliberately via the explicit-grant API. */
|
||||
export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
|
||||
"core.authorization.grants.manage",
|
||||
);
|
||||
|
||||
const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([
|
||||
Object.freeze({
|
||||
baseline: "restricted" as const,
|
||||
description: "Create, list, and revoke another user's explicit authorization grants.",
|
||||
id: CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Core's own permission registry — the trusted source of truth `apps/api`'s authorization
|
||||
* enforcement consults so an HTTP route can never supply its own baseline. Contains exactly the
|
||||
* one Core-owned permission this slice needs; a future module registration mechanism is
|
||||
* deliberately out of scope here and will compose its own declarations through
|
||||
* `createPermissionRegistry` rather than through this constant.
|
||||
*/
|
||||
export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry(
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
);
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
parseGrantId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import { and, eq, gt, isNull, or, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm";
|
||||
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||
import type {
|
||||
ActiveGrantQuery,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
|
||||
export type AuthorizationStoreErrorCode =
|
||||
| "AUTHORIZATION_GRANT_CREATE_FAILED"
|
||||
| "AUTHORIZATION_GRANT_LIST_FAILED"
|
||||
| "AUTHORIZATION_GRANT_LOOKUP_FAILED"
|
||||
| "AUTHORIZATION_GRANT_NOT_FOUND"
|
||||
| "AUTHORIZATION_GRANT_REVOKE_FAILED"
|
||||
@@ -38,6 +39,7 @@ export type AuthorizationStoreErrorCode =
|
||||
|
||||
const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({
|
||||
AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.",
|
||||
AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.",
|
||||
AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.",
|
||||
AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.",
|
||||
AUTHORIZATION_GRANT_REVOKE_FAILED: "The authorization grant could not be revoked.",
|
||||
@@ -90,6 +92,18 @@ export interface GrantRecord {
|
||||
export interface RevokeGrantInput {
|
||||
readonly grantId: GrantId;
|
||||
readonly actor: AuthorizationActor;
|
||||
/** When provided, the revoke only matches a grant whose `sourceKind` equals this value — a
|
||||
* grant of a different source kind is treated exactly like a nonexistent one
|
||||
* (`AUTHORIZATION_GRANT_NOT_FOUND`). The generic explicit-grant HTTP API always passes
|
||||
* `"explicit"` here, so it can never revoke a relationship-derived grant regardless of caller
|
||||
* intent; internal Core callers that omit this may revoke any grant, matching pass 1's
|
||||
* original, unconstrained behavior. */
|
||||
readonly sourceKind?: GrantSourceKind;
|
||||
}
|
||||
|
||||
export interface ListGrantsForUserOptions {
|
||||
/** When provided, only grants with this exact `sourceKind` are returned. */
|
||||
readonly sourceKind?: GrantSourceKind;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,6 +117,12 @@ export interface RevokeGrantInput {
|
||||
export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort {
|
||||
createGrant(input: CreateGrantInput): Promise<GrantRecord>;
|
||||
revokeGrant(input: RevokeGrantInput): Promise<void>;
|
||||
/** Every grant ever recorded for the user, active or revoked, newest first — the caller decides
|
||||
* what to display. Never includes another user's grants. */
|
||||
listGrantsForUser(
|
||||
userId: EkklesiaUserId,
|
||||
options?: ListGrantsForUserOptions,
|
||||
): Promise<readonly GrantRecord[]>;
|
||||
grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||
revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||
}
|
||||
@@ -193,7 +213,13 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
||||
revokedByActorType: input.actor.type,
|
||||
})
|
||||
.where(
|
||||
and(eq(ekklesiaCoreGrant.id, input.grantId), isNull(ekklesiaCoreGrant.revokedAt)),
|
||||
and(
|
||||
eq(ekklesiaCoreGrant.id, input.grantId),
|
||||
isNull(ekklesiaCoreGrant.revokedAt),
|
||||
...(input.sourceKind === undefined
|
||||
? []
|
||||
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!row) {
|
||||
@@ -243,6 +269,29 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
||||
}
|
||||
},
|
||||
|
||||
listGrantsForUser: async (
|
||||
userId: EkklesiaUserId,
|
||||
options: ListGrantsForUserOptions = {},
|
||||
): Promise<readonly GrantRecord[]> => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(ekklesiaCoreGrant)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreGrant.userId, userId),
|
||||
...(options.sourceKind === undefined
|
||||
? []
|
||||
: [eq(ekklesiaCoreGrant.sourceKind, options.sourceKind)]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(ekklesiaCoreGrant.createdAt));
|
||||
return Object.freeze(rows.map(toGrantRecord));
|
||||
} catch {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
grantPlatformOwner: async (
|
||||
userId: EkklesiaUserId,
|
||||
actor: AuthorizationActor,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { safeParseEkklesiaUserId } from "@ekklesia/contracts";
|
||||
import {
|
||||
bootstrapPlatformOwner,
|
||||
createAuthorizationDrizzleDatabase,
|
||||
PlatformOwnerBootstrapError,
|
||||
} from "../authorization/index.js";
|
||||
|
||||
class BootstrapCliError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BootstrapCliError";
|
||||
}
|
||||
}
|
||||
|
||||
const usage =
|
||||
"Usage: pnpm authorization:bootstrap-owner -- --user-id <id> --confirm-user-id <same-id>";
|
||||
|
||||
interface ParsedArgs {
|
||||
readonly confirmUserId: string;
|
||||
readonly userId: string;
|
||||
}
|
||||
|
||||
/** A minimal, hand-rolled flag parser — this command takes exactly two required, named flags, so
|
||||
* a general-purpose argument-parsing dependency is not warranted. */
|
||||
function parseArgs(argv: readonly string[]): ParsedArgs {
|
||||
let userId: string | undefined;
|
||||
let confirmUserId: string | undefined;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const flag = argv[index];
|
||||
if (flag === "--user-id") {
|
||||
userId = argv[index + 1];
|
||||
index += 1;
|
||||
} else if (flag === "--confirm-user-id") {
|
||||
confirmUserId = argv[index + 1];
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
if (userId === undefined || confirmUserId === undefined) {
|
||||
throw new BootstrapCliError(usage);
|
||||
}
|
||||
return { confirmUserId, userId };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { confirmUserId, userId: rawUserId } = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Deliberately not "infer the first user" or "select by signup order" — the operator must name
|
||||
// the exact, opaque Ekklesia user id twice, and both must match exactly, before anything else
|
||||
// is even attempted.
|
||||
if (rawUserId !== confirmUserId) {
|
||||
throw new BootstrapCliError(
|
||||
"--user-id and --confirm-user-id must match exactly. No changes were made.",
|
||||
);
|
||||
}
|
||||
|
||||
const parsedUserId = safeParseEkklesiaUserId(rawUserId);
|
||||
if (!parsedUserId.success) {
|
||||
throw new BootstrapCliError("The provided user id is not a valid Ekklesia user id.");
|
||||
}
|
||||
|
||||
// Read only when this command actually executes — never merely by importing this module (see
|
||||
// isDirectExecution() below) or any module it imports.
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||
throw new BootstrapCliError("DATABASE_URL is required.");
|
||||
}
|
||||
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, {
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 10_000,
|
||||
});
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: parsedUserId.data });
|
||||
console.log(`Platform-owner bootstrap complete for user "${parsedUserId.data}".`);
|
||||
} finally {
|
||||
await close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function reportBootstrapCliError(error: unknown): void {
|
||||
if (error instanceof BootstrapCliError) {
|
||||
console.error(error.message);
|
||||
return;
|
||||
}
|
||||
if (error instanceof PlatformOwnerBootstrapError) {
|
||||
console.error(`[${error.code}] ${error.message}`);
|
||||
return;
|
||||
}
|
||||
console.error("Platform-owner bootstrap failed unexpectedly.");
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
const entrypoint = process.argv[1];
|
||||
return (
|
||||
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
main().catch((error: unknown) => {
|
||||
reportBootstrapCliError(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./pool.js";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -108,3 +108,14 @@ export function createIdentityDrizzleDatabase(
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./authorization/index.js";
|
||||
export * from "./database/index.js";
|
||||
export * from "./diagnostics/index.js";
|
||||
export * from "./identity/index.js";
|
||||
export * from "./modules/index.js";
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseEkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
bootstrapPlatformOwner,
|
||||
createAuthorizationDrizzleDatabase,
|
||||
PlatformOwnerBootstrapError,
|
||||
} from "../src/authorization/index.js";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "../src/migrations/index.js";
|
||||
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const coreMigrationsDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
const trackedDatabases = new Set<string>();
|
||||
let databaseCounter = 0;
|
||||
|
||||
class IntegrationHarnessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "IntegrationHarnessError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function harnessError(message: string): IntegrationHarnessError {
|
||||
return new IntegrationHarnessError(message);
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw harnessError("The bootstrap integration database is not configured.");
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||
throw new TypeError("Unsupported protocol.");
|
||||
}
|
||||
} catch {
|
||||
throw harnessError("The bootstrap integration database configuration is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function quoteDatabaseName(databaseName: string): string {
|
||||
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
||||
throw harnessError("The generated integration database name is invalid.");
|
||||
}
|
||||
return `"${databaseName}"`;
|
||||
}
|
||||
|
||||
function makeDatabaseName(label: string): string {
|
||||
const safeLabel = label
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/gu, "_")
|
||||
.replaceAll(/^_+|_+$/gu, "")
|
||||
.slice(0, 14);
|
||||
databaseCounter += 1;
|
||||
return `ekk_bootstrap_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
function databaseUrl(databaseName: string): string {
|
||||
const parsed = new URL(baseDatabaseUrl());
|
||||
parsed.pathname = `/${databaseName}`;
|
||||
parsed.searchParams.delete("application_name");
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
||||
const client = new Client({ connectionString });
|
||||
client.on("error", () => undefined);
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// The public harness failure remains static when connection cleanup also fails.
|
||||
}
|
||||
throw harnessError("The bootstrap integration database connection failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeQuery<Row extends QueryResultRow>(
|
||||
client: Client,
|
||||
text: string,
|
||||
values: unknown[] = [],
|
||||
): Promise<readonly Row[]> {
|
||||
try {
|
||||
const result = await client.query<Row>(text, values);
|
||||
return result.rows;
|
||||
} catch {
|
||||
throw harnessError("An integration database administration operation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeEnd(client: Client): Promise<void> {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
throw harnessError("An integration database connection could not be closed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function createDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function dropDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(
|
||||
admin,
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
||||
[databaseName],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
||||
);
|
||||
trackedDatabases.delete(databaseName);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||
const name = makeDatabaseName(label);
|
||||
await createDatabase(name);
|
||||
trackedDatabases.add(name);
|
||||
return { name, url: databaseUrl(name) };
|
||||
}
|
||||
|
||||
async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const emptyModulesDirectory = await mkdtemp(
|
||||
path.join(tmpdir(), "ekklesia-bootstrap-no-modules-"),
|
||||
);
|
||||
try {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function seedUser(client: Client, id: string, email: string): Promise<void> {
|
||||
await safeQuery(
|
||||
client,
|
||||
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
|
||||
values ($1, $2, $3, false, now(), now())`,
|
||||
[id, "Integration Test User", email],
|
||||
);
|
||||
}
|
||||
|
||||
async function ownerRowCount(client: Client): Promise<number> {
|
||||
const rows = await safeQuery<{ count: string }>(
|
||||
client,
|
||||
`select count(*)::text as count from "ekklesia_core_platform_owner"`,
|
||||
);
|
||||
return Number(rows[0]?.count ?? "0");
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const databaseName of [...trackedDatabases]) {
|
||||
await dropDatabase(databaseName).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
describe.sequential("live platform-owner bootstrap", () => {
|
||||
let database: { name: string; url: string };
|
||||
let admin: Client;
|
||||
let db: ReturnType<typeof createAuthorizationDrizzleDatabase>["db"];
|
||||
let close: () => Promise<void>;
|
||||
|
||||
const firstUserId = parseEkklesiaUserId("bootstrap_user_one");
|
||||
const secondUserId = parseEkklesiaUserId("bootstrap_user_two");
|
||||
|
||||
it("bootstraps successfully for an existing user and records exactly one audit entry with the fixed bootstrap actor identity", async () => {
|
||||
database = await createDisposableDatabase("success");
|
||||
await applyCoreMigrations(database.url);
|
||||
|
||||
admin = await openAdministrativeClient(database.url);
|
||||
await seedUser(admin, firstUserId, "[email protected]");
|
||||
await seedUser(admin, secondUserId, "[email protected]");
|
||||
|
||||
const handle = createAuthorizationDrizzleDatabase(database.url);
|
||||
db = handle.db;
|
||||
close = handle.close;
|
||||
|
||||
await bootstrapPlatformOwner(db, { userId: firstUserId });
|
||||
|
||||
const owners = await safeQuery<{ user_id: string }>(
|
||||
admin,
|
||||
`select user_id from "ekklesia_core_platform_owner"`,
|
||||
);
|
||||
expect(owners).toEqual([{ user_id: firstUserId }]);
|
||||
|
||||
const audit = await safeQuery<{
|
||||
action: string;
|
||||
actor_id: string | null;
|
||||
actor_type: string;
|
||||
subject_user_id: string;
|
||||
}>(
|
||||
admin,
|
||||
`select action, actor_id, actor_type, subject_user_id from "ekklesia_core_authorization_audit"`,
|
||||
);
|
||||
expect(audit).toEqual([
|
||||
{
|
||||
action: "platform_owner.grant",
|
||||
actor_id: "authorization-bootstrap-cli",
|
||||
actor_type: "system",
|
||||
subject_user_id: firstUserId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a second bootstrap attempt outright, even for a different user, and leaves no new row or audit entry", async () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: secondUserId });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PlatformOwnerBootstrapError);
|
||||
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" });
|
||||
|
||||
expect(await ownerRowCount(admin)).toBe(1);
|
||||
const audit = await safeQuery(admin, `select id from "ekklesia_core_authorization_audit"`);
|
||||
expect(audit).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still refuses bootstrap after the existing owner is revoked — the historical row alone is enough to have consumed it", async () => {
|
||||
await safeQuery(
|
||||
admin,
|
||||
`update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`,
|
||||
[firstUserId],
|
||||
);
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: secondUserId });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PlatformOwnerBootstrapError);
|
||||
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" });
|
||||
expect(await ownerRowCount(admin)).toBe(1);
|
||||
});
|
||||
|
||||
it("shuts down cleanly", async () => {
|
||||
await close();
|
||||
await safeEnd(admin);
|
||||
await dropDatabase(database.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live platform-owner bootstrap — nonexistent user", () => {
|
||||
it("rolls back entirely for a user that does not exist, leaving no owner row and no audit entry", async () => {
|
||||
const database = await createDisposableDatabase("missing_user");
|
||||
await applyCoreMigrations(database.url);
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
||||
|
||||
try {
|
||||
const missingUserId = parseEkklesiaUserId("bootstrap_user_never_seeded");
|
||||
let captured: unknown;
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: missingUserId });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PlatformOwnerBootstrapError);
|
||||
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND" });
|
||||
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
expect(await ownerRowCount(admin)).toBe(0);
|
||||
const audit = await safeQuery(admin, `select id from "ekklesia_core_authorization_audit"`);
|
||||
expect(audit).toHaveLength(0);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
} finally {
|
||||
await close();
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live platform-owner bootstrap — concurrency", () => {
|
||||
it("produces exactly one owner when two bootstrap attempts race for two different users", async () => {
|
||||
const database = await createDisposableDatabase("race");
|
||||
await applyCoreMigrations(database.url);
|
||||
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
const raceUserA = parseEkklesiaUserId("bootstrap_race_user_a");
|
||||
const raceUserB = parseEkklesiaUserId("bootstrap_race_user_b");
|
||||
await seedUser(admin, raceUserA, "[email protected]");
|
||||
await seedUser(admin, raceUserB, "[email protected]");
|
||||
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
bootstrapPlatformOwner(db, { userId: raceUserA }),
|
||||
bootstrapPlatformOwner(db, { userId: raceUserB }),
|
||||
]);
|
||||
|
||||
const fulfilled = results.filter((result) => result.status === "fulfilled");
|
||||
const rejected = results.filter((result) => result.status === "rejected");
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(
|
||||
PlatformOwnerBootstrapError,
|
||||
);
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({
|
||||
code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED",
|
||||
});
|
||||
|
||||
const owners = await safeQuery<{ user_id: string }>(
|
||||
admin,
|
||||
`select user_id from "ekklesia_core_platform_owner"`,
|
||||
);
|
||||
expect(owners).toHaveLength(1);
|
||||
expect([raceUserA, raceUserB]).toContain(owners[0]?.user_id);
|
||||
|
||||
const audit = await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`,
|
||||
);
|
||||
expect(audit).toHaveLength(1);
|
||||
} finally {
|
||||
await close();
|
||||
await safeEnd(admin);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
import { createAuthorizationDrizzleDatabase } from "../src/authorization/database.js";
|
||||
import {
|
||||
bootstrapPlatformOwner,
|
||||
PlatformOwnerBootstrapError,
|
||||
} from "../src/authorization/bootstrap.js";
|
||||
|
||||
describe("Core authorization bootstrap — import and construction safety", () => {
|
||||
it("does not construct a pool or open a connection merely because the module is imported", () => {
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("performs no database work merely by being imported: bootstrapPlatformOwner is only a function reference until called", () => {
|
||||
expect(typeof bootstrapPlatformOwner).toBe("function");
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlatformOwnerBootstrapError", () => {
|
||||
it.each([
|
||||
"PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED",
|
||||
"PLATFORM_OWNER_BOOTSTRAP_FAILED",
|
||||
"PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND",
|
||||
] as const)("exposes a stable code and a fixed, generic message for %s", (code) => {
|
||||
const error = new PlatformOwnerBootstrapError(code);
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.name).toBe("PlatformOwnerBootstrapError");
|
||||
expect(Object.isFrozen(error)).toBe(true);
|
||||
expect(error.message.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never carries a cause, raw driver error, SQL, or stack detail beyond the fixed message", () => {
|
||||
const error = new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_FAILED");
|
||||
const publicText = `${String(error)}\n${error.stack ?? ""}`;
|
||||
expect(publicText).toBe(
|
||||
`PlatformOwnerBootstrapError: ${error.message}\nPlatformOwnerBootstrapError: ${error.message}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrapPlatformOwner — construction without a real database", () => {
|
||||
it("does not construct a pool merely because a database handle was created; only calling bootstrapPlatformOwner would query", () => {
|
||||
poolConstructor.mockClear();
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
|
||||
const { db } = createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(db).toBeDefined();
|
||||
// bootstrapPlatformOwner is intentionally not invoked here: exercising the real transactional
|
||||
// and advisory-lock behavior requires a real PostgreSQL server and is covered by
|
||||
// core/tests/authorization-bootstrap.integration.ts.
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Pool } from "pg";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
@@ -7,6 +8,7 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
import {
|
||||
AuthorizationDatabaseError,
|
||||
createAuthorizationDrizzleDatabase,
|
||||
createAuthorizationDrizzleView,
|
||||
} from "../src/authorization/database.js";
|
||||
|
||||
describe("Core authorization Drizzle database composition", () => {
|
||||
@@ -144,3 +146,19 @@ describe("Core authorization Drizzle database composition", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAuthorizationDrizzleView", () => {
|
||||
it("wraps an externally owned pool without constructing, registering an error listener on, or closing one itself", () => {
|
||||
poolConstructor.mockClear();
|
||||
const fakePool = { end: vi.fn(), on: vi.fn(), query: vi.fn() };
|
||||
|
||||
// A minimal fake pg.Pool double is sufficient — this test proves
|
||||
// createAuthorizationDrizzleView never touches the pool beyond wrapping it.
|
||||
const db = createAuthorizationDrizzleView(fakePool as unknown as Pool);
|
||||
|
||||
expect(db).toBeDefined();
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(fakePool.on).not.toHaveBeenCalled();
|
||||
expect(fakePool.end).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { parsePermissionId } from "@ekklesia/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
createPermissionRegistry,
|
||||
PermissionRegistryError,
|
||||
} from "../src/authorization/index.js";
|
||||
|
||||
describe("createPermissionRegistry", () => {
|
||||
it("builds an immutable registry from a fixed declaration list", () => {
|
||||
const id = parsePermissionId("test.example.read");
|
||||
const registry = createPermissionRegistry([
|
||||
{ baseline: "public", description: "An example permission.", id },
|
||||
]);
|
||||
|
||||
expect(registry.has(id)).toBe(true);
|
||||
expect(registry.get(id)).toEqual({
|
||||
baseline: "public",
|
||||
description: "An example permission.",
|
||||
id,
|
||||
});
|
||||
expect(Object.isFrozen(registry)).toBe(true);
|
||||
expect(Object.isFrozen(registry.declarations)).toBe(true);
|
||||
expect(Object.isFrozen(registry.get(id))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a duplicate permission id at composition time, not per request", () => {
|
||||
const id = parsePermissionId("test.example.read");
|
||||
expect(() =>
|
||||
createPermissionRegistry([
|
||||
{ baseline: "public", description: "First.", id },
|
||||
{ baseline: "restricted", description: "Second.", id },
|
||||
]),
|
||||
).toThrow(PermissionRegistryError);
|
||||
});
|
||||
|
||||
it("carries the offending permission id on the duplicate-declaration error (a composition-time, developer-facing detail, not a runtime response)", () => {
|
||||
const id = parsePermissionId("test.example.read");
|
||||
let captured: unknown;
|
||||
try {
|
||||
createPermissionRegistry([
|
||||
{ baseline: "public", description: "First.", id },
|
||||
{ baseline: "restricted", description: "Second.", id },
|
||||
]);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(PermissionRegistryError);
|
||||
expect(captured).toMatchObject({
|
||||
code: "PERMISSION_REGISTRY_DUPLICATE_DECLARATION",
|
||||
permissionId: id,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an unregistered permission the same as a malformed one: get/has both fail closed", () => {
|
||||
const registered = parsePermissionId("test.example.read");
|
||||
const unregistered = parsePermissionId("test.example.unregistered");
|
||||
const registry = createPermissionRegistry([
|
||||
{ baseline: "public", description: "Registered.", id: registered },
|
||||
]);
|
||||
|
||||
expect(registry.has(unregistered)).toBe(false);
|
||||
expect(registry.get(unregistered)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed for a malformed permission id passed to get/has, without throwing", () => {
|
||||
const registry = createPermissionRegistry([]);
|
||||
|
||||
expect(registry.has("not a valid permission")).toBe(false);
|
||||
expect(registry.get(null)).toBeUndefined();
|
||||
expect(registry.get(undefined)).toBeUndefined();
|
||||
expect(registry.get(42)).toBeUndefined();
|
||||
expect(registry.get("media.*")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds an empty registry from an empty list without error", () => {
|
||||
const registry = createPermissionRegistry([]);
|
||||
expect(registry.declarations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
it("registers exactly core.authorization.grants.manage as restricted", () => {
|
||||
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_AUTHORIZATION_GRANTS_MANAGE);
|
||||
expect(declaration).toBeDefined();
|
||||
expect(declaration?.baseline).toBe("restricted");
|
||||
expect(declaration?.id).toBe("core.authorization.grants.manage");
|
||||
});
|
||||
|
||||
it("contains no other permission in this slice", () => {
|
||||
expect(CORE_PERMISSION_REGISTRY.declarations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
describe("bootstrap-platform-owner CLI import safety", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
it("does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module", async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
|
||||
// false the same way it naturally is for apps/api/src/server.ts under its own test suite —
|
||||
// no argv manipulation is needed to prove the guard holds.
|
||||
await expect(import("../src/cli/bootstrap-platform-owner.js")).resolves.toBeDefined();
|
||||
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
|
||||
process.env = originalEnv;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
import { createValidatedPostgresPool, PostgresPoolError } from "../src/database/pool.js";
|
||||
|
||||
describe("createValidatedPostgresPool", () => {
|
||||
it("does not construct a pool merely because the module is imported", () => {
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
" ",
|
||||
"not-a-database-url",
|
||||
"https://admin:[email protected]/database",
|
||||
])("rejects an invalid connection string without exposing it (%s)", (connectionString) => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createValidatedPostgresPool(connectionString);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PostgresPoolError);
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_POOL_CONFIGURATION_INVALID" });
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
if (connectionString) {
|
||||
expect(publicText).not.toContain(connectionString);
|
||||
}
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explicitly constructs the pool only when called", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
|
||||
const handle = createValidatedPostgresPool(
|
||||
"postgresql://operator:[email protected]/ekklesia",
|
||||
);
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(handle.pool).toBeDefined();
|
||||
expect(typeof handle.close).toBe("function");
|
||||
expect(Object.isFrozen(handle)).toBe(true);
|
||||
});
|
||||
|
||||
it("passes the connection timeout through to the pool configuration", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
poolConstructor.mockClear();
|
||||
|
||||
createValidatedPostgresPool("postgresql://example.test/ekklesia", {
|
||||
connectionTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the operation timeout through as both query_timeout and statement_timeout", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
poolConstructor.mockClear();
|
||||
|
||||
createValidatedPostgresPool("postgresql://example.test/ekklesia", {
|
||||
operationTimeoutMs: 7_500,
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ query_timeout: 7_500, statement_timeout: 7_500 }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["operationTimeoutMs", 0],
|
||||
["operationTimeoutMs", -1],
|
||||
["operationTimeoutMs", Number.NaN],
|
||||
["connectionTimeoutMs", 0],
|
||||
["connectionTimeoutMs", -1],
|
||||
] as const)("rejects an invalid %s (%s) without constructing a pool and without leaking the value", (optionName, value) => {
|
||||
poolConstructor.mockClear();
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
createValidatedPostgresPool("postgresql://example.test/ekklesia", {
|
||||
[optionName]: value,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PostgresPoolError);
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_POOL_CONFIGURATION_INVALID" });
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers a pool-level error listener so an idle client failure never becomes an uncaught exception", () => {
|
||||
const on = vi.fn();
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on };
|
||||
});
|
||||
|
||||
createValidatedPostgresPool("postgresql://example.test/ekklesia");
|
||||
|
||||
expect(on).toHaveBeenCalledWith("error", expect.any(Function));
|
||||
const handler = on.mock.calls.find((call) => call[0] === "error")?.[1] as
|
||||
| ((error: unknown) => void)
|
||||
| undefined;
|
||||
expect(() =>
|
||||
handler?.(new Error("terminating connection due to administrator command")),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { Pool } from "pg";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
import { createIdentityDrizzleDatabase, IdentityDatabaseError } from "../src/identity/database.js";
|
||||
import {
|
||||
createIdentityDrizzleDatabase,
|
||||
createIdentityDrizzleView,
|
||||
IdentityDatabaseError,
|
||||
} from "../src/identity/database.js";
|
||||
|
||||
describe("Core identity Drizzle database composition", () => {
|
||||
it("does not construct a pool merely because the module is imported", () => {
|
||||
@@ -141,3 +146,19 @@ describe("Core identity Drizzle database composition", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createIdentityDrizzleView", () => {
|
||||
it("wraps an externally owned pool without constructing, registering an error listener on, or closing one itself", () => {
|
||||
poolConstructor.mockClear();
|
||||
const fakePool = { end: vi.fn(), on: vi.fn(), query: vi.fn() };
|
||||
|
||||
// A minimal fake pg.Pool double is sufficient — this test proves createIdentityDrizzleView
|
||||
// never touches the pool beyond wrapping it.
|
||||
const db = createIdentityDrizzleView(fakePool as unknown as Pool);
|
||||
|
||||
expect(db).toBeDefined();
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(fakePool.on).not.toHaveBeenCalled();
|
||||
expect(fakePool.end).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user