feat: establish core identity foundation
Adds Core-owned identity contracts, a Core-owned Better Auth database schema (ekklesia_core_user/session/account/verification), a first-class Core-owned migration source that coexists with module-owned migrations under the exact existing migration system, and an explicit Better Auth construction boundary. No authentication endpoint or sign-in UI is exposed yet (ADR 0010). Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
df0f20d8af
commit
679a5d225c
@@ -0,0 +1,243 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_MIGRATION_SOURCE_ID,
|
||||
discoverAllMigrations,
|
||||
discoverCoreMigrations,
|
||||
} from "../src/migrations/index.js";
|
||||
import { ModuleMigrationDiscoveryError } from "../src/modules/index.js";
|
||||
|
||||
const originSnapshotId = "00000000-0000-0000-0000-000000000000";
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
async function createTemporaryDirectory(prefix: string): Promise<string> {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), prefix));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
/** Writes a single-migration Drizzle directory (journal + snapshot + policy + SQL). */
|
||||
async function writeMigrationsDirectory(
|
||||
migrationsDirectory: string,
|
||||
moduleId: string,
|
||||
tag: string,
|
||||
sql: string,
|
||||
): Promise<string> {
|
||||
await mkdir(path.join(migrationsDirectory, "meta"), { recursive: true });
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(migrationsDirectory, "meta/_journal.json"),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
entries: [{ idx: 0, version: "7", when: 1_700_000_000_000, tag, breakpoints: true }],
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(migrationsDirectory, "meta/_ekklesia.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
moduleId,
|
||||
entries: [{ tag, destructive: { mode: "none" } }],
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(migrationsDirectory, "meta/0000_snapshot.json"),
|
||||
JSON.stringify({
|
||||
version: "7",
|
||||
dialect: "postgresql",
|
||||
id: "10000000-0000-4000-8000-000000000001",
|
||||
prevId: originSnapshotId,
|
||||
}),
|
||||
),
|
||||
writeFile(path.join(migrationsDirectory, `${tag}.sql`), sql),
|
||||
]);
|
||||
return sql;
|
||||
}
|
||||
|
||||
async function createModule(
|
||||
modulesDirectory: string,
|
||||
id: string,
|
||||
options: { readonly dependsOn?: readonly string[] } = {},
|
||||
): Promise<void> {
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
const migrationsDirectory = path.join(moduleDirectory, "drizzle");
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
const requiredDependencies = Object.fromEntries(
|
||||
(options.dependsOn ?? []).map((dependencyId) => [dependencyId, "^0.1.0"]),
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
name: `@test/module-${id}`,
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
ekklesia: { layer: "module" },
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
manifestVersion: 2,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
||||
dependencies: {
|
||||
modules: { required: requiredDependencies, optional: {} },
|
||||
capabilities: { required: {}, optional: {} },
|
||||
},
|
||||
capabilities: [],
|
||||
permissions: [],
|
||||
entrypoints: { server: "./dist/server/index.js", migrations: "./drizzle" },
|
||||
}),
|
||||
),
|
||||
writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
|
||||
),
|
||||
]);
|
||||
|
||||
await writeMigrationsDirectory(
|
||||
migrationsDirectory,
|
||||
id,
|
||||
"0000_initial",
|
||||
`CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function sha256(contents: string): string {
|
||||
return createHash("sha256").update(contents).digest("hex");
|
||||
}
|
||||
|
||||
describe("Core-owned migration discovery", () => {
|
||||
it("discovers Core's own migrations with the reserved core identity and an exact checksum", async () => {
|
||||
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
|
||||
const sql = await writeMigrationsDirectory(
|
||||
coreMigrationsDirectory,
|
||||
"core",
|
||||
"0000_identity_core_tables",
|
||||
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
|
||||
);
|
||||
|
||||
const migrations = await discoverCoreMigrations({ coreMigrationsDirectory });
|
||||
|
||||
expect(CORE_MIGRATION_SOURCE_ID).toBe("core");
|
||||
expect(migrations).toHaveLength(1);
|
||||
expect(migrations[0]).toMatchObject({
|
||||
approvalRequired: false,
|
||||
checksum: sha256(sql),
|
||||
identity: "core:0000_identity_core_tables",
|
||||
index: 0,
|
||||
moduleId: "core",
|
||||
tag: "0000_identity_core_tables",
|
||||
});
|
||||
});
|
||||
|
||||
it("is deterministic across repeated discovery calls", async () => {
|
||||
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
|
||||
await writeMigrationsDirectory(
|
||||
coreMigrationsDirectory,
|
||||
"core",
|
||||
"0000_identity_core_tables",
|
||||
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
|
||||
);
|
||||
|
||||
const first = await discoverCoreMigrations({ coreMigrationsDirectory });
|
||||
const second = await discoverCoreMigrations({ coreMigrationsDirectory });
|
||||
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it("orders Core migrations before every module migration while preserving dependency-first module order", async () => {
|
||||
const modulesDirectory = await createTemporaryDirectory("ekklesia-modules-");
|
||||
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
|
||||
await writeMigrationsDirectory(
|
||||
coreMigrationsDirectory,
|
||||
"core",
|
||||
"0000_identity_core_tables",
|
||||
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
|
||||
);
|
||||
// "calendar" depends on "people"; created on disk in the opposite order so only the
|
||||
// dependency graph (not directory order) can explain the resulting sequence.
|
||||
await createModule(modulesDirectory, "calendar", { dependsOn: ["people"] });
|
||||
await createModule(modulesDirectory, "people");
|
||||
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0" },
|
||||
});
|
||||
|
||||
expect(aggregate.modules.map((module) => module.moduleId)).toEqual([
|
||||
"core",
|
||||
"people",
|
||||
"calendar",
|
||||
]);
|
||||
expect(aggregate.migrations.map((migration) => migration.identity)).toEqual([
|
||||
"core:0000_identity_core_tables",
|
||||
"people:0000_initial",
|
||||
"calendar:0000_initial",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an installed module that reuses the reserved core migration identity", async () => {
|
||||
const modulesDirectory = await createTemporaryDirectory("ekklesia-modules-");
|
||||
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
|
||||
await writeMigrationsDirectory(
|
||||
coreMigrationsDirectory,
|
||||
"core",
|
||||
"0000_identity_core_tables",
|
||||
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
|
||||
);
|
||||
await createModule(modulesDirectory, "core");
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0" },
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(ModuleMigrationDiscoveryError);
|
||||
expect((captured as ModuleMigrationDiscoveryError).issues).toEqual([
|
||||
expect.objectContaining({ code: "MODULE_MIGRATION_ID_DUPLICATE", moduleId: "core" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a fresh, module-free repository honestly reporting only Core's migration", async () => {
|
||||
const modulesDirectory = await createTemporaryDirectory("ekklesia-modules-");
|
||||
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
|
||||
await writeMigrationsDirectory(
|
||||
coreMigrationsDirectory,
|
||||
"core",
|
||||
"0000_identity_core_tables",
|
||||
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
|
||||
);
|
||||
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0" },
|
||||
});
|
||||
|
||||
expect(aggregate.discoveredModules).toEqual([]);
|
||||
expect(aggregate.modules).toHaveLength(1);
|
||||
expect(aggregate.modules[0]?.moduleId).toBe("core");
|
||||
expect(aggregate.migrations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -88,6 +88,11 @@ PostgreSQL
|
||||
○ Migration plan skipped
|
||||
A validated migration inventory and configured PostgreSQL connection are required.
|
||||
|
||||
Identity
|
||||
○ Identity configuration skipped
|
||||
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
|
||||
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
||||
|
||||
Object storage
|
||||
○ Check unavailable
|
||||
Core storage integration has not been implemented yet.
|
||||
|
||||
@@ -200,6 +200,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
"migrations.discovery",
|
||||
"postgresql.connection",
|
||||
"postgresql.plan",
|
||||
"identity.configuration",
|
||||
"object-storage.integration",
|
||||
]);
|
||||
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
||||
@@ -438,4 +439,73 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
expect(services.discoverSchemas).toHaveBeenCalledOnce();
|
||||
expect(services.discoverMigrations).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("skips identity configuration (not a failure) when none of it is set", async () => {
|
||||
const report = await diagnoseEkklesia(options(), dependencies());
|
||||
|
||||
const identity = check(report, "identity.configuration");
|
||||
expect(identity.status).toBe("skipped");
|
||||
expect(identity.details[0]).toContain("Core identity schema defines 4 tables");
|
||||
expect(report.result).toBe("ready-with-warnings");
|
||||
expect(doctorExitCode(report)).toBe(0);
|
||||
});
|
||||
|
||||
it("passes identity configuration when a valid secret, base URL, and trusted origins are provided", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
identityConfiguration: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
environment: "production",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const identity = check(report, "identity.configuration");
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture
|
||||
});
|
||||
|
||||
it("fails identity configuration for an invalid secret without leaking it", async () => {
|
||||
const hostileSecret = "s3cr3t-do-not-log";
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
identityConfiguration: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
environment: "production",
|
||||
secret: hostileSecret,
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const identity = check(report, "identity.configuration");
|
||||
expect(identity.status).toBe("failure");
|
||||
expect(identity.details.some((detail) => detail.includes("SECRET_TOO_SHORT"))).toBe(true);
|
||||
expect(report.result).toBe("not-ready");
|
||||
expect(doctorExitCode(report)).toBe(1);
|
||||
expect(JSON.stringify(report)).not.toContain(hostileSecret);
|
||||
});
|
||||
|
||||
it("never constructs a migration database or Better Auth merely to check identity configuration", async () => {
|
||||
const services = dependencies();
|
||||
await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
identityConfiguration: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
},
|
||||
services,
|
||||
);
|
||||
|
||||
expect(services.createMigrationDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createIdentityConfig, IdentityConfigError } from "../src/identity/config.js";
|
||||
|
||||
const validSecret = "correct-horse-battery-staple-0123456789";
|
||||
const validBaseUrl = "https://identity.example.test";
|
||||
const validTrustedOrigins = ["https://identity.example.test", "https://dashboard.example.test"];
|
||||
|
||||
function validInput() {
|
||||
return { baseUrl: validBaseUrl, secret: validSecret, trustedOrigins: validTrustedOrigins };
|
||||
}
|
||||
|
||||
describe("Core identity configuration", () => {
|
||||
it("accepts a valid secret, base URL, and trusted origins", () => {
|
||||
const config = createIdentityConfig(validInput(), { environment: "production" });
|
||||
|
||||
expect(config).toEqual({
|
||||
baseUrl: validBaseUrl,
|
||||
secret: validSecret,
|
||||
trustedOrigins: validTrustedOrigins,
|
||||
});
|
||||
expect(Object.isFrozen(config)).toBe(true);
|
||||
expect(Object.isFrozen(config.trustedOrigins)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a missing secret", () => {
|
||||
expect(() =>
|
||||
createIdentityConfig({ ...validInput(), secret: undefined }, { environment: "production" }),
|
||||
).toThrow(IdentityConfigError);
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), secret: undefined });
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ code: "SECRET_MISSING" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a secret shorter than 32 characters", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), secret: "x".repeat(31) });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "SECRET_TOO_SHORT" });
|
||||
});
|
||||
|
||||
it("accepts a secret of exactly 32 characters", () => {
|
||||
const secret = "aB1cD2eF3gH4iJ5kL6mN7oP8qR9sT0uV";
|
||||
expect(secret).toHaveLength(32);
|
||||
const config = createIdentityConfig({ ...validInput(), secret });
|
||||
expect(config.secret).toBe(secret);
|
||||
});
|
||||
|
||||
it("rejects a degenerate single-character-repeated secret", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), secret: "a".repeat(40) });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "SECRET_LOW_ENTROPY" });
|
||||
});
|
||||
|
||||
it("rejects an invalid base URL", () => {
|
||||
for (const baseUrl of [undefined, "", "not-a-url", "ftp://example.test", "example.test"]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), baseUrl });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(IdentityConfigError);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing trusted origins", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), trustedOrigins: undefined });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "TRUSTED_ORIGINS_MISSING" });
|
||||
});
|
||||
|
||||
it("rejects an empty trusted origins array", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), trustedOrigins: [] });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "TRUSTED_ORIGINS_EMPTY" });
|
||||
});
|
||||
|
||||
it("rejects a trusted origin containing a path, query, or fragment", () => {
|
||||
for (const origin of [
|
||||
"https://example.test/",
|
||||
"https://example.test/path",
|
||||
"https://example.test#fragment",
|
||||
]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), trustedOrigins: [origin] });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "TRUSTED_ORIGIN_INVALID" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects wildcard trusted origins", () => {
|
||||
for (const origin of ["https://*.example.test", "https://app?.example.test"]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), trustedOrigins: [origin] });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects insecure http origins and loopback hosts in production", () => {
|
||||
let httpError: unknown;
|
||||
try {
|
||||
createIdentityConfig(
|
||||
{ ...validInput(), trustedOrigins: ["http://identity.example.test"] },
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
httpError = error;
|
||||
}
|
||||
expect(httpError).toMatchObject({ code: "TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION" });
|
||||
|
||||
let loopbackError: unknown;
|
||||
try {
|
||||
createIdentityConfig(
|
||||
{ ...validInput(), baseUrl: "https://localhost", trustedOrigins: ["https://localhost"] },
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
loopbackError = error;
|
||||
}
|
||||
expect(loopbackError).toMatchObject({ code: "BASE_URL_LOOPBACK_IN_PRODUCTION" });
|
||||
});
|
||||
|
||||
it("defaults to production validation when environment is not specified", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), baseUrl: "http://identity.example.test" });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "BASE_URL_INSECURE_IN_PRODUCTION" });
|
||||
});
|
||||
|
||||
it("explicitly allows local development origins only when environment is development", () => {
|
||||
const config = createIdentityConfig(
|
||||
{
|
||||
baseUrl: "http://localhost:3000",
|
||||
secret: validSecret,
|
||||
trustedOrigins: ["http://localhost:3000"],
|
||||
},
|
||||
{ environment: "development" },
|
||||
);
|
||||
|
||||
expect(config.baseUrl).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
it("never leaks the secret, base URL, or trusted origins through a thrown error", () => {
|
||||
const hostileSecret = "s3cr3t-do-not-log-0123456789abcdef";
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ baseUrl: "not-a-url", secret: hostileSecret, trustedOrigins: [] });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(IdentityConfigError);
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}\n${JSON.stringify(captured)}`;
|
||||
expect(publicText).not.toContain(hostileSecret);
|
||||
expect(publicText).not.toContain(validBaseUrl);
|
||||
for (const origin of validTrustedOrigins) {
|
||||
expect(publicText).not.toContain(origin);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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";
|
||||
|
||||
describe("Core identity Drizzle database composition", () => {
|
||||
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 {
|
||||
createIdentityDrizzleDatabase(connectionString);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(IdentityDatabaseError);
|
||||
expect(captured).toMatchObject({ code: "IDENTITY_DATABASE_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, with the identity schema attached", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined };
|
||||
});
|
||||
|
||||
const handle = createIdentityDrizzleDatabase(
|
||||
"postgresql://operator:[email protected]/ekklesia",
|
||||
);
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(handle.db).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 };
|
||||
});
|
||||
poolConstructor.mockClear();
|
||||
|
||||
createIdentityDrizzleDatabase("postgresql://example.test/ekklesia", {
|
||||
connectionTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const betterAuthMock = vi.hoisted(() => vi.fn());
|
||||
const drizzleAdapterMock = vi.hoisted(() =>
|
||||
vi.fn((db: unknown, options: unknown) => ({ __adapterOptions: options, db })),
|
||||
);
|
||||
|
||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||
|
||||
import type { IdentityConfig } from "../src/identity/config.js";
|
||||
import { createIdentityAuth, IdentityConstructionError } from "../src/identity/factory.js";
|
||||
import { identityAdapterSchema } from "../src/identity/schema.js";
|
||||
|
||||
function validConfig(): IdentityConfig {
|
||||
return Object.freeze({
|
||||
baseUrl: "https://identity.example.test",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: Object.freeze(["https://identity.example.test"]),
|
||||
});
|
||||
}
|
||||
|
||||
describe("Core identity Better Auth construction boundary", () => {
|
||||
beforeEach(() => {
|
||||
betterAuthMock.mockReset();
|
||||
drizzleAdapterMock.mockClear();
|
||||
});
|
||||
|
||||
it("constructs explicitly with narrow injected dependencies and returns only a success marker", () => {
|
||||
betterAuthMock.mockReturnValue({ api: {}, handler: () => undefined });
|
||||
const fakeDb = { __fakeDb: true };
|
||||
|
||||
const result = createIdentityAuth({ config: validConfig(), db: fakeDb as never });
|
||||
|
||||
expect(result).toEqual({ constructed: true });
|
||||
expect(Object.keys(result)).toEqual(["constructed"]);
|
||||
expect(Object.isFrozen(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not expose the raw database, adapter, or Better Auth instance on the returned value", () => {
|
||||
betterAuthMock.mockReturnValue({ api: {}, handler: () => undefined });
|
||||
const fakeDb = { __fakeDb: true };
|
||||
|
||||
const result = createIdentityAuth({ config: validConfig(), db: fakeDb as never });
|
||||
|
||||
expect(JSON.stringify(result)).not.toContain("__fakeDb");
|
||||
expect(JSON.stringify(result)).not.toContain("handler");
|
||||
});
|
||||
|
||||
it("passes the explicit ekklesia_core_* schema mapping to the Drizzle adapter", () => {
|
||||
betterAuthMock.mockReturnValue({});
|
||||
const fakeDb = { __fakeDb: true };
|
||||
|
||||
createIdentityAuth({ config: validConfig(), db: fakeDb as never });
|
||||
|
||||
expect(drizzleAdapterMock).toHaveBeenCalledTimes(1);
|
||||
const [db, options] = drizzleAdapterMock.mock.calls[0] as [unknown, unknown];
|
||||
expect(db).toBe(fakeDb);
|
||||
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
|
||||
});
|
||||
|
||||
it("enables no provider or plugin beyond the four core models", () => {
|
||||
betterAuthMock.mockReturnValue({});
|
||||
|
||||
createIdentityAuth({ config: validConfig(), db: {} as never });
|
||||
|
||||
expect(betterAuthMock).toHaveBeenCalledTimes(1);
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
expect(Object.keys(options).sort()).toEqual([
|
||||
"baseURL",
|
||||
"database",
|
||||
"secret",
|
||||
"trustedOrigins",
|
||||
]);
|
||||
expect(options.baseURL).toBe(validConfig().baseUrl);
|
||||
expect(options.secret).toBe(validConfig().secret);
|
||||
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
|
||||
});
|
||||
|
||||
it("sanitizes a construction failure without leaking the underlying error, secret, or URL", () => {
|
||||
const hostileSecret = validConfig().secret;
|
||||
betterAuthMock.mockImplementation(() => {
|
||||
throw new Error(
|
||||
`Better Auth failed for secret ${hostileSecret} at https://internal.example.test`,
|
||||
);
|
||||
});
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityAuth({ config: validConfig(), db: {} as never });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(IdentityConstructionError);
|
||||
expect(captured).toMatchObject({ code: "IDENTITY_CONSTRUCTION_FAILED" });
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain(hostileSecret);
|
||||
expect(publicText).not.toContain("internal.example.test");
|
||||
});
|
||||
|
||||
it("sanitizes an adapter construction failure the same way", () => {
|
||||
drizzleAdapterMock.mockImplementation(() => {
|
||||
throw new Error("adapter exploded with postgresql://admin:[email protected]/ekklesia");
|
||||
});
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityAuth({ config: validConfig(), db: {} as never });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(IdentityConstructionError);
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain("db.internal");
|
||||
expect(publicText).not.toContain("admin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { getTableName } from "drizzle-orm";
|
||||
import { getTableConfig } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ekklesiaCoreAccount,
|
||||
ekklesiaCoreSession,
|
||||
ekklesiaCoreUser,
|
||||
ekklesiaCoreVerification,
|
||||
identityAdapterSchema,
|
||||
} from "../src/identity/schema.js";
|
||||
|
||||
describe("Core identity Drizzle schema", () => {
|
||||
it("uses explicit, unambiguous ekklesia_core_* physical table names", () => {
|
||||
expect(getTableName(ekklesiaCoreUser)).toBe("ekklesia_core_user");
|
||||
expect(getTableName(ekklesiaCoreSession)).toBe("ekklesia_core_session");
|
||||
expect(getTableName(ekklesiaCoreAccount)).toBe("ekklesia_core_account");
|
||||
expect(getTableName(ekklesiaCoreVerification)).toBe("ekklesia_core_verification");
|
||||
});
|
||||
|
||||
it("explicitly maps every Better Auth logical model to its physical table object", () => {
|
||||
expect(identityAdapterSchema).toEqual({
|
||||
account: ekklesiaCoreAccount,
|
||||
session: ekklesiaCoreSession,
|
||||
user: ekklesiaCoreUser,
|
||||
verification: ekklesiaCoreVerification,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives the user table a unique email and a default-false verification flag", () => {
|
||||
const config = getTableConfig(ekklesiaCoreUser);
|
||||
const columns = Object.fromEntries(config.columns.map((column) => [column.name, column]));
|
||||
|
||||
expect(columns.id?.primary).toBe(true);
|
||||
expect(columns.email?.isUnique).toBe(true);
|
||||
expect(columns.email?.notNull).toBe(true);
|
||||
expect(columns.email_verified?.hasDefault).toBe(true);
|
||||
expect(columns.email_verified?.notNull).toBe(true);
|
||||
});
|
||||
|
||||
it("scopes session and account rows to a user with cascading delete", () => {
|
||||
for (const table of [ekklesiaCoreSession, ekklesiaCoreAccount]) {
|
||||
const config = getTableConfig(table);
|
||||
expect(config.foreignKeys).toHaveLength(1);
|
||||
const foreignKey = config.foreignKeys[0];
|
||||
if (!foreignKey) {
|
||||
throw new Error("Expected a foreign key.");
|
||||
}
|
||||
const reference = foreignKey.reference();
|
||||
expect(reference.columns.map((column) => column.name)).toEqual(["user_id"]);
|
||||
expect(reference.foreignColumns.map((column) => column.name)).toEqual(["id"]);
|
||||
expect(getTableName(reference.foreignTable)).toBe("ekklesia_core_user");
|
||||
expect(foreignKey.onDelete).toBe("cascade");
|
||||
}
|
||||
});
|
||||
|
||||
it("indexes session.user_id, account.user_id, and verification.identifier for lookup", () => {
|
||||
expect(
|
||||
getTableConfig(ekklesiaCoreSession).indexes.map((index) =>
|
||||
index.config.columns.map((column) => ("name" in column ? column.name : undefined)),
|
||||
),
|
||||
).toEqual([["user_id"]]);
|
||||
expect(
|
||||
getTableConfig(ekklesiaCoreAccount).indexes.map((index) =>
|
||||
index.config.columns.map((column) => ("name" in column ? column.name : undefined)),
|
||||
),
|
||||
).toEqual([["user_id"]]);
|
||||
expect(
|
||||
getTableConfig(ekklesiaCoreVerification).indexes.map((index) =>
|
||||
index.config.columns.map((column) => ("name" in column ? column.name : undefined)),
|
||||
),
|
||||
).toEqual([["identifier"]]);
|
||||
});
|
||||
|
||||
it("requires a unique session token", () => {
|
||||
const config = getTableConfig(ekklesiaCoreSession);
|
||||
const token = config.columns.find((column) => column.name === "token");
|
||||
expect(token?.isUnique).toBe(true);
|
||||
expect(token?.notNull).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps every table in the default public PostgreSQL schema", () => {
|
||||
for (const table of [
|
||||
ekklesiaCoreUser,
|
||||
ekklesiaCoreSession,
|
||||
ekklesiaCoreAccount,
|
||||
ekklesiaCoreVerification,
|
||||
]) {
|
||||
expect(getTableConfig(table).schema).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client, type QueryResult, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import { reportMigrationCommandError } from "../src/cli/migration-command-errors.js";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverCoreMigrations,
|
||||
type MigrationApproval,
|
||||
type MigrationApprovalProvider,
|
||||
MigrationRunnerError,
|
||||
@@ -16,6 +19,12 @@ import type {
|
||||
ModuleMigrationAggregate,
|
||||
} from "../src/modules/migration-discovery.js";
|
||||
|
||||
const coreMigrationsDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const historyTable = "public.ekklesia_core_migrations";
|
||||
const migrationLockNamespace = 1_162_562_380;
|
||||
@@ -1236,3 +1245,215 @@ $body$;\n`;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const coreIdentityTables = Object.freeze([
|
||||
"ekklesia_core_user",
|
||||
"ekklesia_core_session",
|
||||
"ekklesia_core_account",
|
||||
"ekklesia_core_verification",
|
||||
]);
|
||||
|
||||
async function loadRealCoreMigrations(): Promise<readonly DiscoveredModuleMigration[]> {
|
||||
const migrations = await discoverCoreMigrations({ coreMigrationsDirectory });
|
||||
if (migrations.length === 0) {
|
||||
throw harnessError("The committed Core identity migration is missing.");
|
||||
}
|
||||
return migrations;
|
||||
}
|
||||
|
||||
interface IndexRow extends QueryResultRow {
|
||||
readonly indexname: string;
|
||||
readonly tablename: string;
|
||||
}
|
||||
|
||||
async function coreIdentityIndexNames(client: Client): Promise<readonly string[]> {
|
||||
const result = await safeQuery<IndexRow>(
|
||||
client,
|
||||
`SELECT tablename, indexname FROM pg_catalog.pg_indexes
|
||||
WHERE schemaname = 'public' AND tablename = ANY($1::text[])
|
||||
ORDER BY tablename, indexname`,
|
||||
[coreIdentityTables],
|
||||
);
|
||||
return Object.freeze(result.rows.map((row) => row.indexname));
|
||||
}
|
||||
|
||||
describe.sequential("live PostgreSQL Core identity migration execution", () => {
|
||||
it("plans the committed Core identity migration on a fresh database without mutation", async () => {
|
||||
await withIsolatedDatabase("core_fresh_plan", async (database) => {
|
||||
const coreMigrations = await loadRealCoreMigrations();
|
||||
const inventory = aggregate(moduleFixture("core", coreMigrations));
|
||||
const before = await databaseState(database.client);
|
||||
|
||||
const plan = await planModuleMigrations({
|
||||
aggregate: inventory,
|
||||
database: createPostgresMigrationDatabase(database.url),
|
||||
});
|
||||
|
||||
expect(plan.entries.map((entry) => entry.status)).toEqual(
|
||||
coreMigrations.map(() => "pending"),
|
||||
);
|
||||
expect(await databaseState(database.client)).toEqual(before);
|
||||
expect(await relationExists(database.client, historyTable)).toBe(false);
|
||||
for (const table of coreIdentityTables) {
|
||||
expect(await relationExists(database.client, `public.${table}`)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the committed Core identity migration and creates exactly the expected tables and indexes", async () => {
|
||||
await withIsolatedDatabase("core_apply", async (database) => {
|
||||
const coreMigrations = await loadRealCoreMigrations();
|
||||
const inventory = aggregate(moduleFixture("core", coreMigrations));
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: inventory,
|
||||
database: createPostgresMigrationDatabase(database.url),
|
||||
});
|
||||
|
||||
expect(result.applied.map((entry) => entry.identity)).toEqual(
|
||||
coreMigrations.map((entry) => entry.identity),
|
||||
);
|
||||
for (const table of coreIdentityTables) {
|
||||
expect(await relationExists(database.client, `public.${table}`)).toBe(true);
|
||||
}
|
||||
expect(await coreIdentityIndexNames(database.client)).toEqual([
|
||||
"ekklesia_core_account_pkey",
|
||||
"ekklesia_core_account_user_id_idx",
|
||||
"ekklesia_core_session_pkey",
|
||||
"ekklesia_core_session_token_unique",
|
||||
"ekklesia_core_session_user_id_idx",
|
||||
"ekklesia_core_user_email_unique",
|
||||
"ekklesia_core_user_pkey",
|
||||
"ekklesia_core_verification_identifier_idx",
|
||||
"ekklesia_core_verification_pkey",
|
||||
]);
|
||||
|
||||
const history = await readHistory(database.client);
|
||||
expect(history).toHaveLength(coreMigrations.length);
|
||||
expect(history.map(({ applied_at: _appliedAt, ...row }) => row)).toEqual(
|
||||
coreMigrations.map((entry, index) => ({
|
||||
applied_order: index + 1,
|
||||
checksum: entry.checksum,
|
||||
identity: entry.identity,
|
||||
migration_index: entry.index,
|
||||
migration_tag: entry.tag,
|
||||
module_id: "core",
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("performs a genuine no-op on a second Core identity apply", async () => {
|
||||
await withIsolatedDatabase("core_no_op", async (database) => {
|
||||
const coreMigrations = await loadRealCoreMigrations();
|
||||
const inventory = aggregate(moduleFixture("core", coreMigrations));
|
||||
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||
await applyModuleMigrations({ aggregate: inventory, database: migrationDatabase });
|
||||
const before = await databaseState(database.client);
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: inventory,
|
||||
database: migrationDatabase,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
|
||||
expect(await databaseState(database.client)).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
it("coexists with a fixture module migration, executing Core first as documented", async () => {
|
||||
await withIsolatedDatabase("core_and_module", async (database) => {
|
||||
const coreMigrations = await loadRealCoreMigrations();
|
||||
const people = peopleInitial();
|
||||
const inventory = aggregate(
|
||||
moduleFixture("core", coreMigrations),
|
||||
moduleFixture("people", [people]),
|
||||
);
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: inventory,
|
||||
database: createPostgresMigrationDatabase(database.url),
|
||||
});
|
||||
|
||||
expect(result.applied.map((entry) => entry.identity)).toEqual([
|
||||
...coreMigrations.map((entry) => entry.identity),
|
||||
people.identity,
|
||||
]);
|
||||
for (const table of coreIdentityTables) {
|
||||
expect(await relationExists(database.client, `public.${table}`)).toBe(true);
|
||||
}
|
||||
expect(await relationExists(database.client, "public.people_records")).toBe(true);
|
||||
const history = await readHistory(database.client);
|
||||
expect(history.map((row) => row.module_id)).toEqual([
|
||||
...coreMigrations.map(() => "core"),
|
||||
"people",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not corrupt previously valid module-only history when Core is introduced later", async () => {
|
||||
await withIsolatedDatabase("core_added_later", async (database) => {
|
||||
const people = peopleInitial();
|
||||
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||
await applyModuleMigrations({
|
||||
aggregate: aggregate(moduleFixture("people", [people])),
|
||||
database: migrationDatabase,
|
||||
});
|
||||
const historyBeforeCore = await readHistory(database.client);
|
||||
|
||||
const coreMigrations = await loadRealCoreMigrations();
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: aggregate(
|
||||
moduleFixture("core", coreMigrations),
|
||||
moduleFixture("people", [people]),
|
||||
),
|
||||
database: migrationDatabase,
|
||||
});
|
||||
|
||||
expect(result.applied.map((entry) => entry.identity)).toEqual(
|
||||
coreMigrations.map((entry) => entry.identity),
|
||||
);
|
||||
const historyAfterCore = await readHistory(database.client);
|
||||
const peopleRowBefore = historyBeforeCore.find((row) => row.module_id === "people");
|
||||
const peopleRowAfter = historyAfterCore.find((row) => row.module_id === "people");
|
||||
expect(peopleRowAfter).toEqual(peopleRowBefore);
|
||||
expect(historyAfterCore).toHaveLength(historyBeforeCore.length + coreMigrations.length);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not damage the committed Core identity migration when a later module migration fails", async () => {
|
||||
await withIsolatedDatabase("core_rollback_safety", async (database) => {
|
||||
const coreMigrations = await loadRealCoreMigrations();
|
||||
const migrationDatabase = createPostgresMigrationDatabase(database.url);
|
||||
await applyModuleMigrations({
|
||||
aggregate: aggregate(moduleFixture("core", coreMigrations)),
|
||||
database: migrationDatabase,
|
||||
});
|
||||
const historyAfterCore = await readHistory(database.client);
|
||||
|
||||
const failing = migration("broken", 0, {
|
||||
sql: `CREATE TABLE "broken_records" ("id" bigint PRIMARY KEY);\n--> statement-breakpoint\nSELECT 1 / 0;\n`,
|
||||
tag: "0000_broken",
|
||||
});
|
||||
|
||||
const error = await captureRunnerError(
|
||||
applyModuleMigrations({
|
||||
aggregate: aggregate(
|
||||
moduleFixture("core", coreMigrations),
|
||||
moduleFixture("broken", [failing]),
|
||||
),
|
||||
database: migrationDatabase,
|
||||
}),
|
||||
"MIGRATION_EXECUTION_FAILED",
|
||||
);
|
||||
|
||||
expect(error.migrationIdentity).toBe(failing.identity);
|
||||
for (const table of coreIdentityTables) {
|
||||
expect(await relationExists(database.client, `public.${table}`)).toBe(true);
|
||||
}
|
||||
expect(await relationExists(database.client, "public.broken_records")).toBe(false);
|
||||
expect(await readHistory(database.client)).toEqual(historyAfterCore);
|
||||
await expectMigrationLockAvailable(database.client);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user