`manifest.storage.buckets` has existed since manifest version 1 and meant nothing — discovery validated it and no code read it. This turns it into a real capability, which is what stands between Announcements and the module system: that product currently fetches a hardcoded LAN S3 endpoint, parses ListObjectsV2 XML by hand, and hands the browser direct object URLs. - A module declares a logical bucket and receives a scoped facade at `context.storage` — list, head, get, put, delete. The contract names no provider: no endpoint, region, credential, or physical bucket is reachable from it, which is what lets the same module run under a future standalone host over its own or a shared S3-compatible service. - Core's first provider speaks S3 through the AWS SDK. SeaweedFS is the deployment target (ADR 0004), never an API name. Probing the pinned image found `CreateBucket` reporting `BucketAlreadyExists` rather than AWS's `BucketAlreadyOwnedByYou`; treating only the AWS spelling as success would have failed every second boot. A live test pins it. - Logical → physical mapping is deterministic and collision-safe: an installation prefix, the module and bucket ids joined by `--` (which cannot occur inside either identifier, so `a-b`+`c` and `a`+`b-c` cannot collide), and a digest over the full untruncated identity. Two modules declaring `assets` always get separate namespaces; neither can see the physical name. - Declaring a bucket is now a requirement: with no provider configured the module is `blocked · storage_unavailable` through ADR 0028's existing resolver, propagating to required dependents. An installation with no object storage stays a complete deployment. - Binding availability is not provider health. Eligibility asks whether storage is configured, never whether it answered; bucket creation is lazy on first use so a storage outage cannot stop the platform booting; a running module whose store blinks stays running and retries. Nothing deletes a bucket. - Credentials are deployment-supplied, never persisted, never routed through module configuration, and never visible to modules, module UI, diagnostics, or API responses. Provider errors translate to a closed code set carrying no endpoint, bucket, request id, or XML. - Retires ADR 0029's transitional `bucket` configuration key. No manifest version change. The template declares no bucket, so it still runs without S3. See ADR 0030. Co-Authored-By: Claude Opus 5 <[email protected]>
202 lines
6.4 KiB
TypeScript
202 lines
6.4 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
type DoctorDependencies,
|
|
diagnoseEkklesia,
|
|
formatDoctorReport,
|
|
} from "../src/diagnostics/index.js";
|
|
import type { MigrationDatabase } from "../src/migrations/index.js";
|
|
import type { ModuleMigrationAggregate, ModuleSchemaAggregate } from "../src/modules/index.js";
|
|
import { composePlatformPermissionRegistry } from "../src/modules/permissions.js";
|
|
|
|
const originalNoColor = process.env.NO_COLOR;
|
|
|
|
afterEach(() => {
|
|
if (originalNoColor === undefined) {
|
|
delete process.env.NO_COLOR;
|
|
} else {
|
|
process.env.NO_COLOR = originalNoColor;
|
|
}
|
|
});
|
|
|
|
function zeroSchemaAggregate(): ModuleSchemaAggregate {
|
|
return Object.freeze({
|
|
discoveredModules: Object.freeze([]),
|
|
drizzleSchema: Object.freeze({}),
|
|
modules: Object.freeze([]),
|
|
tables: Object.freeze([]),
|
|
});
|
|
}
|
|
|
|
function zeroMigrationAggregate(): ModuleMigrationAggregate {
|
|
return Object.freeze({
|
|
discoveredModules: Object.freeze([]),
|
|
migrations: Object.freeze([]),
|
|
modules: Object.freeze([]),
|
|
});
|
|
}
|
|
|
|
function noConfigurationDependencies(): DoctorDependencies {
|
|
return {
|
|
composePermissions: composePlatformPermissionRegistry,
|
|
createMigrationDatabase: () => {
|
|
throw new Error("database must not be constructed");
|
|
},
|
|
discoverMigrations: async () => zeroMigrationAggregate(),
|
|
discoverModules: async () => Object.freeze([]),
|
|
discoverSchemas: async () => zeroSchemaAggregate(),
|
|
planMigrations: async () => {
|
|
throw new Error("database must not be planned");
|
|
},
|
|
probeStorage: async () => {
|
|
throw new Error("storage must not be probed");
|
|
},
|
|
readModuleConfiguration: async () => {
|
|
throw new Error("module configuration must not be read");
|
|
},
|
|
readModuleLifecycle: async () => {
|
|
throw new Error("module lifecycle configuration must not be read");
|
|
},
|
|
runtimeSatisfies: () => true,
|
|
};
|
|
}
|
|
|
|
describe("Ekklesia Doctor terminal report", () => {
|
|
it("renders the real zero-module and no-database shape exactly", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: "/not-rendered/modules",
|
|
platformVersion: "0.1.0",
|
|
},
|
|
runtimeRange: ">=24.11.0 <25",
|
|
runtimeVersion: "24.15.0",
|
|
},
|
|
noConfigurationDependencies(),
|
|
);
|
|
|
|
expect(formatDoctorReport(report).join("\n")).toBe(`Ekklesia Doctor
|
|
|
|
Platform
|
|
✓ Runtime version supported
|
|
Active Node.js satisfies the declared supported range.
|
|
|
|
Modules
|
|
✓ Module discovery complete
|
|
0 modules installed
|
|
✓ Dependency graph valid
|
|
Dependency order is empty.
|
|
✓ Permission registry composed
|
|
5 permissions registered
|
|
○ Module lifecycle skipped
|
|
DATABASE_URL is not configured.
|
|
○ Module configuration skipped
|
|
DATABASE_URL is not configured.
|
|
|
|
Data definitions
|
|
✓ Schemas validated
|
|
0 modules · 0 tables
|
|
✓ Migrations validated
|
|
0 migrations · checksums valid
|
|
|
|
PostgreSQL
|
|
✓ Database TLS verified
|
|
TLS required with certificate and hostname verification.
|
|
○ Connection check skipped
|
|
DATABASE_URL is not configured.
|
|
○ 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.
|
|
✓ Direct client-address mode
|
|
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
|
|
|
Object storage
|
|
○ Object storage not configured
|
|
No S3_ENDPOINT is set. This is a complete deployment unless a module requires storage.
|
|
✓ No module requires object storage
|
|
0 modules declare a logical storage bucket.
|
|
|
|
Result: READY WITH WARNINGS`);
|
|
});
|
|
|
|
it("does not depend on NO_COLOR or terminal interactivity", async () => {
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: "/not-rendered/modules",
|
|
platformVersion: "0.1.0",
|
|
},
|
|
runtimeRange: ">=24.11.0 <25",
|
|
runtimeVersion: "24.15.0",
|
|
},
|
|
noConfigurationDependencies(),
|
|
);
|
|
const baseline = formatDoctorReport(report);
|
|
const originalIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
|
|
|
try {
|
|
process.env.NO_COLOR = "1";
|
|
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
|
|
const interactiveWithoutColor = formatDoctorReport(report);
|
|
process.env.NO_COLOR = "";
|
|
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
|
|
const nonInteractive = formatDoctorReport(report);
|
|
|
|
expect(interactiveWithoutColor).toEqual(baseline);
|
|
expect(nonInteractive).toEqual(baseline);
|
|
expect(interactiveWithoutColor.some((line) => line.includes("\u001B"))).toBe(false);
|
|
expect(Object.isFrozen(interactiveWithoutColor)).toBe(true);
|
|
} finally {
|
|
if (originalIsTtyDescriptor) {
|
|
Object.defineProperty(process.stdout, "isTTY", originalIsTtyDescriptor);
|
|
} else {
|
|
delete (process.stdout as { isTTY?: boolean }).isTTY;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("never renders secrets from a hostile database exception", async () => {
|
|
const hostile =
|
|
"postgresql://admin:[email protected]/db SELECT password FROM users /home/me/.env";
|
|
const database = Object.freeze({}) as MigrationDatabase;
|
|
const dependencies: DoctorDependencies = {
|
|
...noConfigurationDependencies(),
|
|
createMigrationDatabase: () => database,
|
|
planMigrations: async () => {
|
|
throw new Error(hostile);
|
|
},
|
|
};
|
|
const report = await diagnoseEkklesia(
|
|
{
|
|
databaseUrl: "postgresql://another:[email protected]/db",
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: "/not-rendered/modules",
|
|
platformVersion: "0.1.0",
|
|
},
|
|
runtimeRange: ">=24.11.0 <25",
|
|
runtimeVersion: "24.15.0",
|
|
},
|
|
dependencies,
|
|
);
|
|
const output = formatDoctorReport(report).join("\n");
|
|
|
|
for (const forbidden of [
|
|
"admin",
|
|
"secret",
|
|
"host.invalid",
|
|
"SELECT",
|
|
"password",
|
|
"/home/me",
|
|
"postgresql://",
|
|
]) {
|
|
expect(output).not.toContain(forbidden);
|
|
}
|
|
});
|
|
});
|