Commit Graph
101 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5 5c589d8fec fix: enforce explicit grant invariants
Explicit-grant management could produce states an operator could not reason
about, and pushed request-shaped problems into the database.

Two identical createGrant calls produced two grants; five concurrent identical
calls produced five. Revoking one returned success while the equivalent kept
authorizing the same user — an operator could believe they had removed access
that was still live. Two issuances are now the same authorization source when
user_id, permission_id, scope, source_kind, and source_ref are all equal, with
NULL equal only to NULL; expiry, creation metadata, actor, and grant id do not
distinguish them. Creating one while an equivalent is currently effective fails
with AUTHORIZATION_GRANT_ALREADY_EXISTS (HTTP 409, no-store) naming nothing about
the grant that already exists, and writing no grant and no audit row. A revoked
or expired equivalent does not block a fresh issuance. Conflict rather than
idempotent return: the caller asked to record a new authorization with their own
actor and expiry, and answering "done" while recording neither would hide that
nothing happened.

Enforcement is database-backed, since nothing constrains the number of API
processes: every create and revoke touching one equivalence tuple takes a
transaction-scoped advisory lock keyed by a hash of that tuple, encoded as a JSON
array so NULL stays distinct from the empty string and a crafted scope cannot
forge a separator. A collision can only make unrelated tuples serialize, never
hide a conflict, because the decision is always an exact SQL predicate. Its
single-key lock space is verified disjoint from the two-key space the migration
runner and bootstrap use. A time-dependent partial unique index was rejected:
now() is not immutable and cannot be indexed.

One revocation now clears every unrevoked equivalent — including expired-but-
unrevoked legacy rows, so the history is left in one state — with one audit row
per grant actually revoked, each carrying its own id, in one transaction.
Differently scoped or sourced grants are untouched. Historical duplicates are not
silently revoked by migration; the store prevents new effective equivalents and
an operator's explicit revocation resolves the class.

The request boundary no longer leaks into PostgreSQL. GrantId accepts exactly the
canonical lowercase hyphenated UUID the database emits, with no trimming and no
case folding, so a malformed path parameter is a 400 decided after authorization
but before any store call instead of reaching the UUID comparison and returning
an indistinguishable 503; a canonical unknown id still returns 404. expiresAt
accepts exactly YYYY-MM-DDTHH:mm:ss.sssZ, byte-for-byte equal to the parsed
date's own toISOString, and must be strictly future — bare new Date silently
rewrote 2026-02-31 to March 3rd and accepted locale, date-only, offset, and
millisecond-free forms, and a past expiry was stored as a grant dead on arrival.
The store validates the same thing before any SQL so trusted internal callers get
the guarantee too.

The generic HTTP surface now rejects a scope field in every form, including null.
No route middleware supplies a scope to the evaluator, so a scope accepted here
produced a grant that persisted, listed, and authorized nothing. Core's scope
semantics are unchanged for internal callers; no resolver was added.

Listing was unbounded and returned a user's entire history. It is now keyset
pagination over created_at DESC, id DESC with a strict boundary on both values,
so rows sharing a timestamp are neither skipped nor repeated, at most limit + 1
rows read, explicit-source filtering server-side on every page, and an opaque
unsigned position cursor that is strictly decoded and reveals nothing on
rejection. Core's list method requires a validated finite bound with no
unbounded fallback anywhere.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 21:56:32 -05:00
ekklesiaandClaude Opus 5 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]>
2026-07-26 21:54:51 -05:00
ekklesiaandClaude Opus 5 6b6b77c105 fix: enforce production transport policy
Every PostgreSQL connection this repository opened was plaintext, in every
environment: no connection site set `ssl` at all, so credentials, session
tokens, and row data crossed the network in the clear and nothing in
configuration, startup, or `pnpm doctor` said so. The public identity URL
boundary was also looser than it appeared.

`core/src/database/transport.ts` is now the one typed policy. It has exactly two
modes: `verify-full` (TLS required, certificate chain verified, and server
identity verified against the configured host — the production default) and
`disable` (the development/test default, refused outright in production). There
is deliberately no encryption-only `require` mode, no chain-only `verify-ca`,
and no `rejectUnauthorized: false` escape hatch anywhere in the configuration
surface; every loose or aliased spelling is rejected in both environments, so a
typo never quietly becomes a weaker mode. Production has no plaintext exception
at all, not even for a loopback database — a connection string cannot prove the
database is process-local.

Verification is certificate *and* hostname. The policy supplies an explicit
`checkServerIdentity` pinned to the host parsed from DATABASE_URL, because the
pinned driver sets TLS `servername` only for non-IP hosts and Node performs no
identity check when it is unset — so `rejectUnauthorized: true` alone accepts a
certificate from any host the trusted CA ever signed when connecting by IP.
Both halves are proven against a real TLS-enabled PostgreSQL, including that the
same connection without the pinned callback succeeds. Every CA, certificate, and
key is generated at test runtime and deleted afterwards.

A DATABASE_URL may no longer carry its own TLS parameters. The pinned driver
merges parsed connection-string values over explicitly passed options, so a
`?sslmode=disable` in the URL would silently replace the policy's verified `ssl`
object; that now fails with a stable code, reported by both `pnpm doctor` and
the migration CLIs rather than as "failed unexpectedly".

One shared environment reader keeps `process.env` out of Core's reusable
constructors, and every executable that opens a connection resolves the policy
identically: the API (validated before any pool, Better Auth instance, module
runtime, or listener exists), both migration commands, the bootstrap CLI, and
Doctor's own connection. Doctor reports it as a blocking failure, never a
warning, naming only the effective mode.

Separately, the identity base URL and every trusted origin must now be exactly
one canonical origin — the WHATWG origin serialization must equal the supplied
string — so a path, trailing slash, credentials, explicit default port, or
non-canonical case is rejected rather than normalized into something that no
longer matches what a browser sends. Production's loopback rejection now
classifies the address instead of comparing against three literals, covering
`.localhost`, all of 127.0.0.0/8, 0.0.0.0, `::`, expanded and IPv4-mapped
forms. This is a configuration-shape guarantee only: it does not resolve DNS or
prove reachability, and TLS protects the connection, not the exposure.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 17:03:52 -05:00
ekklesiaandClaude Opus 5 7a0f624fc3 fix: distinguish identity session failures
Principal resolution could not tell a broken session store from a genuinely
anonymous request, and did unnecessary database work for callers that never
presented a session at all. Both are corrected here.

Any cookie previously triggered the ambiguity check, so `Cookie: x=y` on an
otherwise anonymous request caused an identity database round trip an
unauthenticated caller could drive with a header they chose themselves.
`hasConfiguredSessionCookie` now parses the Cookie header per RFC 6265 (split
on `;`, name is the text before the first `=`, trimmed) and compares the whole
name exactly — never `includes`, a substring test, or an unanchored regular
expression — so `better-auth.session_token_x`, `x_better-auth.session_token`, a
differently-cased spelling, and a percent-encoded lookalike are all correctly
not the session cookie. A duplicated name still reports true: ambiguity fails
safe towards checking, and reporting true never grants access on its own.

The cookie name is not a second hard-coded copy. One `betterAuthOptions` object
now feeds both `betterAuth()` and Better Auth's own supported `getCookies`
helper, so the detected name — including the `__Secure-` prefix an https baseURL
implies — is derived from the exact configuration Better Auth itself uses, and
drift is structurally impossible rather than merely tested.

The readiness check itself proved almost nothing: `select 1` still succeeded
against a dropped session table, a drifted or removed column, or a revoked
SELECT privilege, so Better Auth's ambiguous null was reported as a genuinely
anonymous request. `probeSessionStore` now plans a real read of the session
relation and the exact columns the adapter depends on, under `where false`:
PostgreSQL resolves the relation, every column, and the caller's privilege at
plan time while matching zero rows, so no session row, token, or user id can be
read, logged, or enumerated by the check. It uses the injected pool, never a
second connection, and stays bounded by the existing timeout.

A missing relation, dropped column, revoked privilege, unreachable connection,
and query timeout now all produce the same sanitized 503 with no relation name,
column name, SQL, driver text, host, or stack — proven against real PostgreSQL,
since that is the only place a broken store looks exactly like an anonymous
request.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 17:02:42 -05:00
ekklesiaandClaude Opus 5 3d6c85b0f8 fix: observe request timeout failures
The sanitized operational line promised for a header/request timeout was
never actually emitted. Driving a real slow-header and slow-body connection
through a real socket against the pinned Node 24 runtime shows the general
server 'timeout' event never fires for headersTimeout/requestTimeout — that
event belongs to server.setTimeout()'s idle-socket timer, which this server
never arms. Both surface as 'clientError' carrying
err.code === "ERR_HTTP_REQUEST_TIMEOUT".

bindServer now observes 'clientError'. Because registering any such listener
suppresses Node's own default response entirely, the handler reproduces that
default byte-for-byte — 408 for a timeout, 431 for a header overflow, 400
otherwise — writing it only when the socket is still writable and no response
has already begun, then destroying the socket exactly as Node would. Exactly
one fixed sanitized line is logged per event, never the caught error's own
message, which for a parse failure derives from attacker-controlled input.

Proof moves to the real transport boundary: raw-socket scenarios assert the
exact response bytes, exactly one status line, socket closure, handler
non-invocation, and exactly one sanitized log for a slow-header connection, a
slow-body connection, a malformed request line, and an oversized header block.
The prior unit test, which manually emitted 'timeout', is removed as
misleading.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 15:59:33 -05:00
ekklesiaandClaude Opus 5 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]>
2026-07-26 15:58:08 -05:00
ekklesiaandClaude Sonnet 5 d3c6bd73d3 fix: enforce API request limits
Add a single platform-owned request-body policy (hono/body-limit) with
small, named limits for Better Auth and Core's grant-mutation endpoint, and
a new required maxBodyBytes field on the executable module route contract so
every module route makes a conscious no-body-or-bounded-body choice, capped
at a platform ceiling. Every oversized request gets one sanitized 413
contract and never reaches business logic, authorization, or a handler.
Also configure finite Node header/request/keep-alive timeouts, including the
non-obvious connectionsCheckingInterval needed for them to take effect
promptly, so a slow-header or slow-body connection cannot hold a socket
indefinitely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 12:29:24 -05:00
ekklesiaandClaude Sonnet 5 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]>
2026-07-26 12:20:51 -05:00
The_River 5e09330001 Merge pull request 'feat: add module database access' (#13) from phase-5/module-database-access into main
CI / verify (push) Successful in 26s
CI / infrastructure-smoke (push) Successful in 19s
CI / migration-smoke (push) Successful in 28s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/13
2026-07-26 03:01:34 +00:00
ekklesia c38b572778 docs: document module database access
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 29s
Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen
ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit
migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015
where their original text is now inaccurate. Sweep docs/development/api.md,
docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md,
and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness;
correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching
gap in README's current-status narrative. Extend the module template with a permissioned
GET /records route reading through context.services.database, using the canonical
createModuleTable helper, with matching template tests.
2026-07-25 21:50:03 -05:00
ekklesia 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.
2026-07-25 21:26:15 -05:00
ekklesia 0e308eb9ad feat: define module database access
Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen
ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object
through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler
context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView)
over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and
wires it into startApi's fixed startup order ahead of Identity/Authorization construction.
Registration stays database-free; a module reaches its database only through an authorized start,
stop, or HTTP handler invocation.
2026-07-25 20:57:32 -05:00
The_River 9c9ede0418 Merge pull request 'feat: add module HTTP transport' (#12) from phase-5/module-http-transport into main
CI / verify (push) Successful in 26s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 27s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/12
2026-07-25 15:44:40 +00:00
ekklesiaandClaude Sonnet 5 300f98c29d fix: normalize final CORS Vary headers
CI / verify (pull_request) Successful in 26s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 27s
Found during a targeted post-audit review: sanitizeModuleResponse's Vary fix
(bf76f4e) canonicalized the module response's own Vary before Hono's own
cors() middleware (apps/api/src/app.ts) ran, but cors() unconditionally
appends its own "Vary: Origin" afterward, outside module-http-routes.ts's
reach. The wire-level response app.fetch actually returns could therefore
still read "*, Origin" for a module handler that sets Vary: *, or carry a
case-different duplicate Origin token — reproduced first via a new app-level
regression test hitting the final response, not sanitizeModuleResponse in
isolation.

Fixed by wrapping the shared corsMiddleware in withCanonicalVary
(apps/api/src/app.ts), which re-canonicalizes whatever Vary cors() leaves
behind only after it has fully finished, handling both the normal
next()-driven path (via c.header(), since reassigning c.res triggers Hono's
own header-preserving merge and would silently undo the fix) and the
OPTIONS-preflight path (cors() returns a Response directly without touching
c.res). Neither path reads or clones the response body.

Refactored module-http-routes.ts's Vary logic into one shared parsing/dedup
primitive with two policy-specific callers: mergedVaryHeader (module
responses; always forces canonical Origin/Cookie, unchanged behavior) and
the new canonicalizeVaryHeader (the app-level CORS boundary; only
deduplicates and collapses "*", never injects a token that was not already
present — an OPTIONS preflight has no Cookie/personalization concept to
force). This also incidentally fixes the same pre-existing Origin
duplication on /api/v1/principal (present since Phase 4B, flagged as a
remaining concern in the prior pass), since it wraps the one shared
middleware every /api/v1/* and /api/auth/* response already passes through.

ADR 0015 required no changes: its Vary/CORS ownership statements are
architectural, not tied to a specific function, and remain accurate.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-25 10:39:06 -05:00
ekklesiaandClaude Sonnet 5 1285c90618 docs: record module HTTP architecture
Phase 5B, pass 3: adds ADR 0015 recording the durable module HTTP
routing and production runtime lifecycle architecture pass 1 and pass
2 implemented, extending ADR 0006 and ADR 0014 without superseding
either. Adds narrow cross-reference notes to both, and to the ADR
index, pointing at ADR 0015 for the work they deferred, while
preserving their historical text and accepted status.

Corrects stale forward-looking "pass 2"/future-tense language left
over from pass 1 in core/src/modules/runtime.ts and
packages/contracts/src/module-http.ts (comment-only; production
behavior unchanged), and a shutdown-lifecycle description in
docs/development/testing.md that predated the module-runtime-stop
step pass 2 added between draining the HTTP server and closing the
pool.

Full audit against origin/main...HEAD found the architecture,
contract, startup ordering, routing, authorization/origin
enforcement, and PostgreSQL integration coverage from passes 1-2
sound; the one concrete defect found (Vary-header merge producing an
invalid "*, Origin, Cookie" combination and case-sensitive duplicate
tokens) was already fixed with focused regression tests in a separate
commit (bf76f4e), not amending either checkpoint commit.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-25 10:22:35 -05:00
ekklesiaandClaude Sonnet 5 bf76f4e006 fix: collapse invalid Vary * combination and case-duplicate tokens
Found during the Phase 5B pass-3 architectural audit: sanitizeModuleResponse's
Vary merge used a plain Set, so an existing "Vary: *" from a module handler
became "*, Origin, Cookie" (RFC 9110 §12.5.5 forbids combining "*" with any
other value), and a differently-cased existing token (e.g. "origin") produced
a redundant duplicate alongside the forced "Origin"/"Cookie". mergedVaryHeader
now de-duplicates case-insensitively, always emits canonical Origin/Cookie
casing, and collapses to a bare "*" whenever one is present, per focused unit
tests. The full app's own pre-existing cors() middleware (apps/api/src/app.ts,
unchanged here, present since Phase 4B on every /api/v1/* route including
/api/v1/principal) still unconditionally appends its own "Vary: Origin"
outside this module's control — noted in the new tests, not addressed by this
narrow fix.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-25 10:14:13 -05:00
ekklesiaandClaude Sonnet 5 dfaf324a65 feat: mount module HTTP routes
Phase 5B, pass 2: production API mounting, authorization enforcement,
and module-runtime lifecycle integration.

apps/api now discovers the installed module inventory exactly once
(prepareStartupModuleInventory), composes the platform permission
registry from it, starts the module runtime from that same discovered
list (startDiscoveredModuleRuntime, never rediscovering), and mounts
StartedModuleRuntime's published routes under
/api/v1/modules/<module-id> via a new apps/api-owned Hono adapter
(module-http-routes.ts). Every module route is protected by the
existing requirePermission middleware against the composed registry,
state-changing methods additionally require a trusted Origin, OPTIONS
remains entirely platform-owned, and a handler that throws, rejects,
or returns a non-Response becomes a fixed, sanitized
500 MODULE_HANDLER_FAILED. Module-supplied CORS headers are stripped
and every module response is forced to Cache-Control: no-store with
Vary: Origin, Cookie. RunningApi shutdown now stops the module runtime
between draining the HTTP server and closing the shared pool, and
every startup failure path stops the runtime and/or closes the pool
before propagating, without masking the original failure.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-24 19:16:20 -05:00
ekklesiaandClaude Sonnet 5 e296e083ec feat: define module HTTP routes
Phase 5B, pass 1: framework-neutral module HTTP route contracts and runtime
registration. Adds @ekklesia/contracts' module-http.ts (methods, canonical
path grammar, route definition/context/handler types, and validation)
and Core's module runtime staging/publication of a module's declared
routes alongside its capabilities, gated on the module's own manifest
permissions. Routes are collected into StartedModuleRuntime as an
immutable snapshot but are not yet mounted into apps/api; that is pass 2.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-24 18:36:23 -05:00
The_River c9cf1f08e3 Merge pull request 'feat: integrate module authorization' (#11) from phase-5/module-authorization-integration into main
CI / verify (push) Successful in 25s
CI / infrastructure-smoke (push) Successful in 19s
CI / migration-smoke (push) Successful in 26s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/11
2026-07-24 23:05:42 +00:00
ekklesia 510d76f7c2 docs: correct module authorization comments
CI / verify (pull_request) Successful in 26s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 27s
2026-07-24 17:59:48 -05:00
ekklesia 9fa051be61 docs: record module authorization architecture
Adds ADR 0014 recording the durable architecture Phase 5A established
across both passes: the shared contracts-layer PermissionDeclaration,
manifest version 3 replacing version 2's bare permission strings, the
reserved core.* namespace and the structural guarantee that a
module-vs-Core collision always surfaces as
ModulePermissionCompositionError rather than the generic
PermissionRegistryError duplicate path, deterministic registry
composition, startup ordering, repository-root/package-version
resolution, registry injection into apps/api's enforcement and
explicit-grant HTTP API, the static-snapshot/historical-record
preservation guarantees, and Doctor/modules:validate integration.
Indexes it in the ADR README, cross-references it from ADR 0006 (only
its manifestVersion 2 / permission-string decision is superseded; the
rest of ADR 0006 remains accepted, and its historical text is left
untouched) and ADR 0013 (module permission registration, deferred
there, is what ADR 0014 implements), and links it from the relevant
development documentation.

Corrects two comments discovered during a direct source audit that
overstated where a module-vs-Core permission collision is actually
caught (core/src/modules/permissions.ts,
apps/api/src/platform-permissions.ts) — comment-only, no behavior
change, already covered by existing tests. Also corrects a
documentation claim in docs/development/authorization.md that
incorrectly implied removing a module blocks explicit revocation of
its historical grants; grants-routes.ts's revoke handler never
consults the permission registry, so revocation remains unaffected.
2026-07-24 17:49:42 -05:00
ekklesia edd7148aa8 feat: integrate module authorization
Wires composePlatformPermissionRegistry into apps/api's production
startup: startApi() discovers every installed module, composes the one
immutable platform permission registry (Core's own declarations plus
every module's), and only then constructs the shared PostgreSQL pool,
Better Auth, and the HTTP server. A discovery or composition failure —
an invalid manifest, a reserved core.* namespace claim, or a duplicate
declaration — propagates before anything exists to close; there is no
Core-only registry fallback. apps/api/src/platform-permissions.ts
resolves the repository root from its own file location, never
process.cwd(), so package-scoped test runs discover the right modules/
directory.

createApiApp/createGrantsRoutes now require the composed registry as
an injected authorization option, replacing the previous hard-coded
CORE_PERMISSION_REGISTRY; requirePermission continues to work
identically for Core-owned and module-owned permissions, since it has
no concept of the distinction.

Extends pnpm doctor with a modules.permissions check (reusing the
already-discovered module list, distinguishing reserved-namespace from
generic-duplicate failures, never re-discovering modules) and pnpm
modules:validate to compose the same registry before a module is
considered installable.

Adds focused coverage: startup-ordering and failure-isolation tests in
apps/api/tests/lifecycle.test.ts, module-owned-declaration enforcement
tests in apps/api/tests/authorization-middleware.test.ts, Doctor
composition tests in core/tests/doctor.test.ts, and a live PostgreSQL
end-to-end scenario proving a real installed module's permission is
discoverable, grantable, and deniable once the module is absent again
after a restart, without ever executing its server entrypoint.
2026-07-24 14:24:50 -05:00
ekklesia d9dd409aa3 feat: declare module permissions
Establishes the one canonical PermissionDeclaration contract
(packages/contracts/src/authorization.ts) — id/baseline/description,
strict, unknown-property-rejecting — consumed by both Core's
authorization registry and module manifests, replacing Core's
previously separate interface.

Bumps the canonical module manifest to manifestVersion 3: permissions
become complete declarations instead of bare identifier strings, with
manifest-owned namespace and duplicate-id enforcement. Version 2 and
bare string-array permissions are rejected outright; there is no
compatibility adapter.

Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts),
a pure, synchronous composer that builds one platform permission
registry from Core's own declarations plus every already-discovered
module's, in deterministic order, reusing createPermissionRegistry
and rejecting any module-contributed permission in the reserved
core.* namespace. core/src/authorization remains independent of
core/src/modules; only the module integration layer depends on
authorization.

Updates the module template, module-development docs, authorization
docs, and existing manifest fixtures for manifestVersion 3. Does not
wire the combined registry into apps/api — that is pass 2.
2026-07-24 12:25:00 -05:00
The_River 79aaab93b6 Merge pull request 'phase-4/core-authorization' (#10) from phase-4/core-authorization into main
CI / verify (push) Successful in 26s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 28s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/10
2026-07-23 16:52:39 +00:00
ekklesia df2af0a3e0 fix: enforce registered authorization permissions
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 11s
CI / migration-smoke (pull_request) Successful in 27s
Two related fail-closed gaps let a well-formed but never-declared
permission id slip through:

- POST /api/v1/grants created explicit grants for any well-formed
  PermissionId without checking the injected permission registry,
  allowing dormant grants for invented identifiers that could become
  effective the moment those identifiers were registered later.
- createAuthorizationEvaluator validated the permission id only inside
  the restricted-baseline branch, so a malformed permission combined
  with the public or authenticated baseline could still return allowed.

Both now fail closed: the grants route checks the target permission
against the trusted registry before calling store.createGrant (sanitized
403, no store or audit write), and the evaluator validates permission
shape before any baseline is allowed to return an allowed decision,
without adding a registry dependency to the evaluator itself.

The live integration lifecycle test switched from an unregistered
calendar.events.read to the registered core.authorization.grants.manage
permission, since module permission registration does not exist yet.
2026-07-23 11:44:45 -05:00
ekklesia 967c275027 docs: record core authorization architecture
Adds ADR 0013 documenting the durable Phase 4C decisions (permission
registry, evaluator baselines, grant/scope semantics, platform-owner
bootstrap, shared database pool, Origin enforcement, and what remains
deferred), cross-links it from the authorization/README docs, and
refreshes the pnpm doctor no-DATABASE_URL example output in README.md
and repository-workflow.md to include the authorization migration.
2026-07-23 11:26:47 -05:00
ekklesia 1363608552 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.
2026-07-23 11:16:32 -05:00
ekklesia 72e082ff2f feat: add core authorization foundation 2026-07-23 10:29:01 -05:00
The_River 3fa1c4cdec Merge pull request 'fix: harden identity HTTP transport' (#9) from phase-4/identity-http-hardening into main
CI / verify (push) Successful in 27s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 25s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/9
2026-07-22 05:00:30 +00:00
ekklesia d9b73fd81a fix: harden identity HTTP transport
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 25s
2026-07-21 23:50:41 -05:00
The_River c91f0c8c6d Merge pull request 'feat: expose core identity transport' (#8) from phase-4/identity-http-transport into main
CI / verify (push) Successful in 26s
CI / infrastructure-smoke (push) Successful in 12s
CI / migration-smoke (push) Successful in 29s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/8
2026-07-22 00:24:19 +00:00
ekklesia 91d8f5946f feat: expose core identity transport
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 19s
CI / migration-smoke (pull_request) Successful in 26s
2026-07-21 19:15:14 -05:00
The_River 82fe338a8d Merge pull request 'feat: establish core identity foundation' (#7) from phase-4/core-identity-foundation into main
CI / verify (push) Successful in 23s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 21s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/7
2026-07-21 22:21:28 +00:00
ekklesiaandClaude Sonnet 5 679a5d225c feat: establish core identity foundation
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 22s
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]>
2026-07-21 16:39:15 -05:00
The_River df0f20d8af Merge pull request 'feat: add Ekklesia doctor command' (#6) from phase-3/developer-doctor into main
CI / verify (push) Successful in 24s
CI / infrastructure-smoke (push) Successful in 19s
CI / migration-smoke (push) Successful in 24s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/6
2026-07-21 15:45:36 +00:00
ekklesia 02ee3ccd80 feat: add Ekklesia doctor command
CI / verify (pull_request) Successful in 20s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 25s
2026-07-21 10:42:21 -05:00
The_River f89d575dd4 Merge pull request 'fix: build workspace packages before migration tests' (#5) from phase-3/migration-integration-tests into main
CI / verify (push) Successful in 21s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 24s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/5
2026-07-21 15:03:21 +00:00
ekklesia 5104f2e954 fix: build workspace packages before migration tests
CI / verify (pull_request) Successful in 22s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 21s
2026-07-21 09:59:30 -05:00
The_River 262e7b7660 Merge pull request 'test: add PostgreSQL migration integration coverage' (#4) from phase-3/migration-integration-tests into main
CI / verify (push) Successful in 18s
CI / infrastructure-smoke (push) Successful in 13s
CI / migration-smoke (push) Failing after 18s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/4
2026-07-21 14:50:19 +00:00
ekklesia 831a48187c test: add PostgreSQL migration integration coverage
CI / verify (pull_request) Successful in 19s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Failing after 16s
2026-07-21 09:37:15 -05:00
The_River b38f85f438 Merge pull request 'feat: add transactional module migration runner' (#3) from phase-3/transactional-migration-runner into main
CI / verify (push) Successful in 19s
CI / infrastructure-smoke (push) Successful in 13s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/3
2026-07-21 13:55:10 +00:00
ekklesia 0b282865cb feat: add transactional module migration runner
CI / verify (pull_request) Successful in 19s
CI / infrastructure-smoke (pull_request) Successful in 20s
2026-07-21 08:52:41 -05:00
The_River dc37e59035 Merge pull request 'feat: add module migration contracts and validation' (#2) from phase-3/migration-contracts into main
CI / verify (push) Successful in 18s
CI / infrastructure-smoke (push) Successful in 20s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/2
2026-07-21 13:12:23 +00:00
ekklesia 88c28d75c9 feat: add module migration contracts and validation
CI / verify (pull_request) Successful in 21s
CI / infrastructure-smoke (pull_request) Successful in 20s
2026-07-21 08:09:30 -05:00
The_River 37f54edd89 Merge pull request 'feat: add module schema discovery and validation' (#1) from phase-3/module-schema-discovery into main
CI / verify (push) Successful in 23s
CI / infrastructure-smoke (push) Successful in 20s
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/1
2026-07-14 02:44:46 +00:00
ekklesia 80b56b72b1 feat: add module schema discovery and validation
CI / verify (pull_request) Successful in 24s
CI / infrastructure-smoke (pull_request) Successful in 20s
2026-07-13 21:35:21 -05:00
ekklesia 2465d9c1f7 runner fix
CI / verify (push) Successful in 24s
CI / infrastructure-smoke (push) Successful in 19s
2026-07-13 20:19:30 -05:00
ekklesia 7b448d8706 feat: add module runtime lifecycle
CI / verify (push) Successful in 2m14s
CI / infrastructure-smoke (push) Failing after 1m36s
2026-07-12 22:49:51 -05:00
ekklesia 57552a21a4 feat: add module contracts and discovery
CI / verify (push) Has been cancelled
CI / infrastructure-smoke (push) Has been cancelled
2026-07-12 22:14:01 -05:00
ekklesia d9489384c1 chore: harden repository guardrails
CI / verify (push) Has been cancelled
CI / infrastructure-smoke (push) Has been cancelled
2026-07-12 21:20:58 -05:00