a835215eb80c0218633f851602d3b1315ffffcc1
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
80189c8ae2 |
feat: give display devices their own paired identity
ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page. Together, the only way to light a television in a foyer was to sign a human being into it and leave that session running on a screen anyone can walk up to: a person's credentials on a wall, a person's session as the thing signage depends on, and the display going dark when they are revoked or their session simply expires. A screen is not a person. Ekklesia now recognizes three principal classes — anonymous, authenticated human, and managed device — and a device is a first-class principal with its own credential and its own explicit permissions. It has no email, no password, and no row in the identity tables, and it satisfies the `authenticated` baseline never, so every route that settles for "somebody is signed in" stays closed to it. Device permissions live in their own table because ekklesia_core_grant.user_id is a non-null foreign key into the user table and making it polymorphic would rewrite the platform's central authorization record to serve one narrow case. That is a schema decision, not a second decision point: the evaluator gained one branch, so a device is allowed or denied by the same function, with the same deny-by-default, as anyone else — with no owner path it could reach and no baseline that stands in for a grant. The six-digit code is not the credential. It is printed across a screen in a public hallway, so it is treated as public: it identifies a request to an administrator and is worth nothing alone. Collecting the credential also requires a 256-bit claim secret held only in the requesting browser's HttpOnly cookie, and approving requires the restricted core.devices.manage permission. Without that split, reading a code off a wall would hand over the screen's identity. The permission a pairing asks for is read from the module's validated display declaration, never from the request, so a browser cannot ask to be granted core.devices.manage or another module's permission. Approval grants exactly that one permission. Credentials are stored only as SHA-256; the raw value exists in the Set-Cookie that issues it and the device's own cookie jar, and is never logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so the module display code running in that same page cannot read it. Revocation is a server-side column checked on every principal resolution, and re-pairing mints a new identity so an old credential can never come back. State transitions are conditional updates, so one code cannot be approved twice or claimed into two credentials under a race. /api/v1/principal still reports a device as anonymous: it is what the Dashboard uses to decide whether a person is signed in. Displays read their viewer from the descriptor instead, which already performed the authorization — so displays are descriptor-first while the Dashboard stays principal-first, and neither infers an identity by elimination. Proven end to end against real PostgreSQL and real HTTP: an unpaired screen shows a code, an owner approves it, the device opens the display descriptor and a realtime stream, and revocation closes both while the device still holds its cookie. An already-open SSE stream is bounded by realtime's existing five-minute reconnect rather than closed instantly, and that bound is documented rather than called immediate. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
fc6c8045c4 | feat: add native module realtime transport | ||
|
|
b9ca0d560d |
feat: add host-provided module object storage
`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]> |
||
|
|
baa689b2a7 |
feat: add the module configuration and secrets contract
Modules had no way to be told anything. Announcements — the first real product Ekklesia will absorb — has zero environment variables and compiles a LAN IP address into its source, so this is the contract standing between it and the module system. - A module declares what it needs in its manifest, using six bounded types (string, integer, boolean, enum, url, secret) with no nesting and no regular expressions. Static declaration is what lets the platform validate configuration *before* deciding whether a module may start: an enabled module missing a required value is reported `blocked · configuration_missing · <key>` rather than taking startup down from inside its own `start` hook, and Doctor reports the same while still importing no module code. That forces `manifestVersion: 5`, a hard cutover. - Ordinary values persist in one Core table (migration 0005), one row per (module_id, key), stored as text until validated against the declaring module's current manifest. Nothing is coerced; an invalid stored value blocks the module rather than falling back to the default. A key the manifest no longer declares is retained and ignored. - Secrets are never persisted. There is no encryption at rest in this repository, so a `secret` key is deployment-supplied and mapped in by the host under a name the module never sees. A secret declaration has no `default` field, the write API refuses secret material, and the HTTP view has no `value` field for one — absent by construction, not filtered. - Modules receive a frozen `context.config` on the registration, runtime, and HTTP handler contexts: the same object identity, per module, beside `services` rather than inside it (services is deliberately one shared instance). No environment access, no database reference, no way to reach another module's values. - Configuration decides eligibility through ADR 0028's existing resolver: three new blocked reasons, each with a distinct operator fix, propagating to required dependents through the same mechanism as a disabled module. - Changes take effect at the next restart; both resources report restartRequired. New restricted permission `core.modules.configuration.manage`, separate from lifecycle, at the sibling prefix `/api/v1/module-configuration`. - Migration participation stays independent of configuration validity. Endpoints and credentials for shared infrastructure stay platform concerns; a module declares which bucket it needs, not an S3 endpoint. See ADR 0029. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
7729c99e24 |
feat: add the minimal operational module lifecycle
Ekklesia had no notion of a module being on or off: the accepted module set was exactly "whatever discovery found on disk", and `ModuleLifecycleStage` named nine states nothing produced or consumed. This replaces that vocabulary with two axes that real code produces — persistent, administrator-owned configuration (`enabled`/`disabled`) and per-boot runtime observation (`running`/`disabled`/`blocked`) — and wires them through startup. - One Core table (`ekklesia_core_module_state`, migration 0004) holding only `module_id` and `enabled`. A module with no record is enabled by default, so deploying a module is still sufficient to run it and discovery never needs an initialization write. A record for an undeployed module is retained and ignored, so redeploying restores the decision. - `resolveModuleEligibility` is pure: a disabled module transitively blocks every module that *requires* it (module and capability edges alike) with a structured reason naming the provider; optional dependencies never block; and a missing, incompatible, or cyclic dependency stays discovery's concern. - `startApi` starts only the eligible set and builds the UI publication registry from that same list, so routes, artifacts, browser inventory, and Dashboard navigation all follow one authoritative set. A disabled module is never imported, so there is no per-request enablement check anywhere. - Startup stays all-or-nothing, deliberately; there is consequently no `failed` runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band recovery path for a module that breaks startup. - Changes take effect at the next start, and the read model reports configuration, runtime, and `restartRequired` separately rather than claiming a module stopped when it has not. - Migration participation and permission composition still span the deployed and discovered inventories respectively, unchanged. - New restricted platform permission `core.modules.lifecycle.manage` gates both reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`) that cannot collide with the module route namespace. - `pnpm doctor` gains a `modules.lifecycle` check so "why is this module unavailable?" is answerable without reading logs. See ADR 0028. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
bd34c9c714 |
feat: add Dashboard navigation as presentation configuration
Phase 6D pass 1. The shell's navigation was a flat list of module ids, which is not a usable experience — and the obvious fix carries an obvious trap. Letting a module declare its folder, or modelling "Media" as something Livestream and Video belong to, turns a rendering preference into architecture: a folder becomes an owner, membership starts to look like a dependency, and the flat module graph acquires a hierarchy nobody decided on. So the rule comes first: a Dashboard group is a label on a rendering bucket. No manifest, no version, no artifact, no API, no lifecycle; nothing depends on it. Livestream depends on Video because it calls Video's functionality, and that orders startup. Livestream is shown under Media, and that orders nothing. No parent module, child module, owning module, or group-derived dependency appears anywhere in the contracts. No manifest change and no version 5. `manifest.name` has been required since version 1 and already is the module's product identity, so the shell renders "Livestream" instead of `livestream` with nothing added. `suggestedGroup` is deliberately deferred rather than shipped: it would force a hard manifest-version cutover for a hint, and its only correct semantics needs initialization logic that risks a suggestion overwriting an administrator's decision. Defaulting and ownership are different things. Two Core tables (migration 0003) hold groups and placements — server-side, because a church configures its Dashboard rather than one browser; platform-global, because no organization model exists to scope them to. `group_id` is `on delete set null`, so deleting a group ungroups its modules and a dangling reference is impossible. `module_id` carries no foreign key, because modules are packages rather than rows, so a placement for an uninstalled module is retained and restores its position on reinstall. `resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced module is ungrouped and visible with no initialization write, an absent module renders nothing, empty groups are kept, and ordering is total with the module id as final tie-break. `GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of the host-neutral module UI inventory rather than merged into it — each generic descriptor is embedded untouched, so a standalone host never inherits Dashboard semantics. Reading needs the permission the inventory already needs; organizing needs the new platform permission `core.dashboard.navigation.manage` through a narrow, idempotent, closed-validation mutation contract. No editor UI. The flatness rule is executable: a test discovers real fixtures with a genuine dependency, arranges them four ways including both in one folder and the dependency inverted in presentation, and asserts discovery order, dependency declarations, and the module set stay byte-identical. The end-to-end test organizes a real installation through the real API, mounts a module from inside a group, moves it, and asserts the navigation entry is byte-identical across the move and still mounts the same artifact. The shell's client for the generic inventory became dead code and was removed rather than kept speculatively; the endpoint itself is unchanged. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
490b85b07c |
fix: bound module lifecycle shutdown
Nothing bounded shutdown, and a module that failed to start was never cleaned up. `startModules` appended to its rollback set only after `start()` resolved, so a hook that acquired a connection, registered a listener, or recorded partial initialization and then threw had its own `stop()` skipped, while every previously completed module was rolled back correctly. A module now joins the rollback set the moment its start hook is invoked: its own stop hook runs first, then earlier modules in reverse order. The original start issue stays first in the reported issues, and a failure stopping the failing module is appended without preventing the rest of the rollback. The consequence for module authors is explicit and documented — `stop()` may run after a partially completed `start()` and must be safe and idempotent about resources it may or may not hold. Neither stage of shutdown had a deadline. `shutdown()` awaited `server.close()` with no bound, so one open connection could hold it forever, and each stop or rollback hook was awaited without a bound, so a hook that never settled kept the pool open indefinitely. Both now have fixed, non-configurable defaults: 10 seconds per module hook and 30 seconds for the graceful HTTP drain. A hook that reaches its deadline records MODULE_STOP_TIMEOUT or MODULE_ROLLBACK_TIMEOUT and the sequence continues with the next module, so the whole stop sequence is finite in the started module count rather than one global ten-second bound. Timers that lose the race are cleared, the losing promise gets a permanent rejection handler so a late throw cannot become an unhandled rejection, and a timed-out hook is never awaited again. At the drain deadline the platform closes idle and then active connections through Node's own public closeIdleConnections/closeAllConnections, after server.close(), touching no private field, and stops waiting for the close callback. It then runs the bounded module stop sequence and closes the pool, so a drain timeout or a stop failure never skips later cleanup. Both are reported together as an ApiShutdownError carrying only fixed booleans and the already sanitized module error; a forced drain is a shutdown failure, not a success. Stated honestly rather than overstated: a deadline bounds how long the platform waits, not how long module code runs. An in-process promise cannot be canceled, and closing a socket does not prove a handler promise ended. What is guaranteed is that shutdown stops waiting and the pool closes afterwards, so late database work fails. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
532ce3803b |
fix: preserve authorization security history
Two security records did not survive user deletion, because both hung off a cascading foreign key to the live Better Auth user row. Deleting a user with authorization history deleted that history. A subject with a create and a revoke audit row had zero rows afterwards: the record of what they had been granted, by whom, and when it was revoked disappeared at exactly the moment it mattered most. `ekklesia_core_authorization_audit.subject_user_id` is now a retained, non-null historical snapshot with no foreign key — a plain identifier that may name a user row which no longer exists. No name, email, or other profile data is copied into authorization tables. The Drizzle relation to the user table is dropped too, because modelling a retained snapshot as a live relation misdescribes the data. Live rows still cascade, as intended: deleting a user removes their grants and platform-owner row, so nothing continues authorizing a principal that no longer exists. Platform-owner bootstrap's single use was equally fragile. "Already consumed" meant "a row exists in ekklesia_core_platform_owner", and that table cascades too — so deleting the bootstrapped owner's user row removed the owner row, its audit evidence, and with them the only thing preventing the operation that establishes a platform's first administrator from being run again. A dedicated `ekklesia_core_platform_owner_bootstrap` singleton now carries that authority. Its id is pinned by a check constraint to the literal 'singleton', so the primary key makes a second row structurally impossible rather than merely unwritten by convention; it has no user foreign key, and Core never deletes from it. Bootstrap claims that record inside the same transaction as the owner row and its audit entry, so consumption is tied to success: a missing target user or any other failure rolls the sentinel back and leaves the one legitimate first use available. The existing transaction-scoped advisory lock still serializes concurrent first attempts to exactly one success. The operational consequence is deliberate and permanent: deleting the last platform owner does not reactivate bootstrap, and neither does revoking one. Core migration 0002_authorization_integrity creates the singleton table, drops the cascading audit-subject constraint, and backfills consumed state for any installation that already holds a platform-owner row — active or revoked — so an existing deployment cannot gain a fresh first use by upgrading. It records no subject, because a backfill cannot know which of several historical owner rows the original use targeted and guessing would fabricate history. A never-owned installation is untouched and keeps its one first use. One upgrade limitation is irrecoverable and is documented rather than papered over: if an operator manually deleted the bootstrapped owner's user row before this migration, the old cascade already erased the owner row and its audit evidence, so nothing remains to detect. At this checkpoint no supported Better Auth or API user-deletion path can produce that state; it requires direct database access. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
1161f5a076 |
fix: fail closed on production signup
Production configuration accepted publicSignupEnabled: true and started a working Identity/API runtime, even though Ekklesia has no verified email, invitation requirement, CAPTCHA, or durable shared/edge limiter — Doctor merely reported "Public signup is explicitly ENABLED" as a pass. validatePublicSignupEnabled now takes the same IdentityEnvironment the rest of this config validates against and throws the stable PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION whenever the parsed value is true in production. Parsing stays separate from the policy, so a malformed value still reports PUBLIC_SIGNUP_ENABLED_INVALID everywhere. Because the executable entry point validates configuration before startApi, such a deployment fails before any pool, Better Auth instance, module runtime, or listener exists, and Doctor reports NOT READY via its existing IdentityConfigError path. Development and test composition still enable it explicitly through the identical typed path. Co-Authored-By: Claude Opus 5 <[email protected]> |
||
|
|
cafd513019 |
fix: secure authentication request identity
Replace Better Auth's default trust of a caller-controlled X-Forwarded-For with a platform-owned client-address resolution boundary: direct mode (the safe default) trusts only the real socket peer, and an explicit trusted-proxy mode requires a header name, a concrete trusted-proxy list, and only honors a forwarded chain from an actual trusted peer. This closes a rate-limit bypass where rotating a spoofed header let a caller choose a fresh bucket on every request. Public sign-up is also disabled by default and requires an explicit typed opt-in, using Better Auth's own disableSignUp gate so no email-existence leak or second signup path is introduced. Co-Authored-By: Claude Sonnet 5 <[email protected]> |
||
|
|
d1b9efd4d2 |
test: prove module database access with PostgreSQL
Add a real installed-module fixture (core/tests/fixtures/module-database-integration/db-facade) with its own manifest, Drizzle schema, drizzle-kit-generated migration, and permissioned HTTP routes, then exercise it end to end in a new apps/api PostgreSQL integration suite: explicit migration application before the module runtime starts (never auto-created), start/stop/HTTP handlers sharing one ModuleDatabase facade by reference, authorized read/mutation/transaction commit and rollback against real PostgreSQL, denied/rejected-origin/OPTIONS/HEAD requests never reaching the module or its table, and both a module-caught and an uncaught real database failure sanitizing exactly as pass 1 established. Registered alongside the existing suites in vitest.migrations.integration.config.ts; no production code, schema, or migration changed. |