The package-release job failed its clean-tree assertion in CI. Reproduced
as root in a container with a bind-mounted checkout, as the Gitea runner
does: after checkout the tree is clean; `pnpm install --frozen-lockfile`
then creates `.pnpm-store/` (17,272 untracked files) at the checkout's
root, because pnpm keeps its store on the node_modules file system and
the checkout is its own mount; `pnpm build` changes nothing. No tracked
file is ever modified.
- `.pnpm-store/` is ignored, like node_modules: a package cache, never
source. The clean-commit requirement is unchanged for every tracked
file and every untracked file Git does not ignore.
- The clean-tree failure now lists `git status --short` and
`git diff --stat`.
- Nothing is built before the clean-source assertion: the gate no longer
runs a global `pnpm build` first. The builder asserts a clean tree,
builds, then fails if its build changed the tree, before packing. Core's
verifier is built after both releases, for the consumer proof only.
- Every secondary offline install (the second worktree, the consumer
module) uses the workspace install's own store explicitly, since pnpm
picks a store per file system and those directories are on another.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Produces the exact public package tarballs of a platform release, validates
them, and proves a standalone module consumes those artifacts end to end.
Nothing is published and no credential is configured.
- scripts/package-release.ts (pnpm packages:release) is the canonical
release builder: from a clean commit it rebuilds the nine public
packages from clean output, packs each, inspects every tarball, checks
the release set and coordinated versions, and only then writes
ekklesia-packages-<version>/ with the tarballs, SHA256SUMS, and a
provider-neutral manifest (ekklesia-package-train.json: version,
revision, and each package's file, size, SHA-256, npm integrity).
- Packing uses a pack-only pnpm beforePacking hook (passed with
--config.pnpmfile, never the workspace's install settings). pnpm inserts
rewritten workspace: dependencies in async completion order, so two
checkouts of one commit packed module-pack's package.json with keys in a
different order; the hook restores the source key order (and the
inspection enforces it). It also drops devDependencies, which named
private packages outside the train.
- scripts/test-package-release.sh (pnpm test:package-release, the CI
package-release gate) builds the train twice, the second time from a
separate worktree, requires byte-identical release directories, then
runs the standalone consumer proof on the release: manifest checked,
offline install from the tarballs only, lockfile integrity equal to the
manifest's, frozen-lockfile reinstall, no Drizzle, one React, contracts
only transitive, every @ekklesia package and module-pack at the release
version, skipLibCheck: false, UI build, pack, and Core verification.
- docs/development/releases.md documents the release order, the train,
the artifacts, and the reproducibility findings; registry publication
remains unimplemented.
- ADR 0053 is Accepted: everything it decides that does not need a
registry is now proven, and registry provisioning is on its own
Deferred list.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Makes the public module-development package set usable from a module
repository outside the monorepo. Nothing is published.
- Curated @ekklesia/module-sdk: an explicit author API, no contracts
barrel, no identity/authorization internals, no Drizzle and no legacy
ModuleDatabase type. defineModuleRuntime returns the SDK's own named
interfaces so a module's emitted declarations never reach into
@ekklesia/contracts. defineModuleSchema is gone; createModuleTable and
defineModuleSchemaMetadata live in @ekklesia/module-database.
- Drizzle: no longer a dependency of module-sdk or contracts. Core keeps
the legacy Drizzle schema validator (moved to core/src/modules) and
its legacy runtime support; drizzle-orm is an optional peer only.
- React is a peer (plus devDependency) of @ekklesia/ui; the boundary
checker allows exactly that consumer-selected peer.
- @ekklesia/typescript-config is publishable; module-ui-host is private.
Every public package carries "ekklesia.distribution":
"module-development" and no monorepo-root prepack.
- module-pack reads the Node.js range from the module's own engines.node
and recognizes Ekklesia packages by name plus that marker, so
registry-installed packages are incorporated, SDK-compatibility
checked, and never credited as third-party.
- scripts/pack-public-packages.ts (pnpm packages:pack) rebuilds, packs
and inspects the nine public tarballs; scripts/test-standalone-module.sh
(pnpm test:standalone-module, new CI job) installs them into a fixture
module outside the repository and proves it typechecks, builds,
packages, and verifies.
- Public-package boundary tests and module-development docs.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The project decision is the GNU Affero General Public License, version 3 or
(at your option) any later version.
- "license": "AGPL-3.0-or-later" in all 14 Ekklesia-owned package.json files.
- README: the License section states AGPL v3 or any later version (SPDX
AGPL-3.0-or-later) and carries the FSF's standard grant: "either version 3 of
the License, or (at your option) any later version", with its warranty
disclaimer and pointer to the license.
- Module template README and module development guide: AGPL-3.0-or-later for
Ekklesia and official modules.
LICENSE is unchanged: it remains the canonical AGPLv3 text.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Ekklesia is open source under the GNU Affero General Public License, version 3
only. Module Package v1 (#66) bundles Ekklesia's SDK and contracts into
distributable module packages, so the platform needs an explicit license
before such artifacts are distributed.
- LICENSE: the GNU AGPL v3 text, verbatim from
https://www.gnu.org/licenses/agpl-3.0.txt
(sha256 0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0).
- "license": "AGPL-3.0-only" in the root package.json and every Ekklesia
workspace package (13 packages), the module template included. Nothing else in
any manifest changed.
- README: a License section replacing the "no license selected yet" placeholder:
AGPL v3 only, source available under its terms, section 13's network-use
obligation, and the policy that official Ekklesia modules use AGPL-3.0-only.
- Module template README and module development guide: the template declares
the license; Module Package v1 does not yet record a module's own license,
which needs a format change under ADR 0049.
AGPL-3.0-only, not -or-later: the decision is AGPL-3.0, and nothing in the
repository grants later versions. No runtime, ADR, package-format, Signage, or
instance change. The packaged template is byte-identical before and after.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A separately versioned module is now distributed as one deterministic
.tar.gz and its SHA-256, produced by an Ekklesia-owned packager and checked by
Core before anything is installed. This slice implements the portable `any`
target, with the module template as the end-to-end proof. ADR 0049 is
accepted.
@ekklesia/contracts: the Module Package v1 contract. The strict
ekklesia.package.json schema (packageFormat 1, identity, target, runtime,
provenance, incorporated Ekklesia packages, strict transitional facts, an
exact byte-wise-sorted file list with size and SHA-256), target names, and the
generated runtime package.json.
@ekklesia/module-pack (new, tooling): `ekklesia-pack-module --module <dir>
--target any --out <dir> --source-revision <commit>`.
- Bundles server, schema, and jobs with esbuild, SDK, contracts, and Drizzle
included; only Node built-ins remain imports. Any warning, unresolved
import, external dependency, or native or platform-specific package fails.
- Copies browser artifacts as built, after proving them current, and the
migrations; generates package.json and ekklesia.package.json; never ships
src, tests, declarations, source maps, or workspace ranges.
- THIRD_PARTY_NOTICES: every third-party package traced from the bundles'
own inputs, credited once with its declared licence and every licence,
copying, copyright, and notice file it ships. A missing licence fails; a
missing text fails unless its standard text is complete without copyright
lines (Apache-2.0, vendored, needed by drizzle-orm).
- Deterministic ustar and gzip: sorted paths, uid/gid 0, empty names, mtime
0, 0755/0644, no links. Byte-identical across runs and machines.
- Writes only the archive and its .sha256; never deploys, links, or installs,
so the workspace's install state is untouched.
Core: verifyModulePackage and `verify-module-package.js <archive>
[--sha256 | --sha256-file] [--extract-to <new dir>]`. Reads the archive in
memory and refuses links, special entries, setuid/setgid/sticky modes, unsafe
or escaping paths, and a second top-level directory; checks metadata, every
file, identity, target, Node.js range, SDK, and the transitional Drizzle
agreement; runs the platform's module and migration discovery; loads the
backend once in a forked process with an empty environment; only then
extracts, with normalized modes, into a new directory. Errors are sanitized.
@ekklesia/module-ui-build: analyzeModuleUi reports each artifact's inputs
and whether the built file is current. The nested stylesheet build now works
from the module directory, so artifacts no longer depend on the caller's
working directory.
The split deploy smoke builds the package with the canonical packager
(scripts/lib/build-module-package.Dockerfile replaces the test-only pnpm
deploy packaging), verifies it with the same generic image, installs the
verified tree, removes it, and reinstalls the exact same archive.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every workspace package declares 0.1.2, as the 0.1.1 release did for
itself, and the deploy README's build example names it. The tag
v0.1.2 created on 12dca3e carries package.json 0.1.1, which the
instance's assemble.sh refuses ("its tag and package.json disagree"),
and the running platform would report 0.1.1.
0.1.2 carries what the first production deployment found:
- #57: Doctor checks the client-address configuration the API runs;
- #58: storage over HTTP/1.1 (repeat image loads no longer fail with
ERR_HTTP2_PROTOCOL_ERROR), and storage CORS owned by the proxy;
- #60: CI stacks never outlive their job;
- #56: display-device smoke scenarios;
- #59, #61: ADR 0047 proposed, and the CI documentation corrected for
Gitea 1.27.2.
templates/module keeps its own version.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Bump the root and every platform workspace package from 0.1.0 to 0.1.1, so
the platform reports the version the v0.1.1 tag will carry. Module discovery
checks compatibility.platform against the root version and
compatibility.moduleSdk against @ekklesia/module-sdk; both stay inside every
existing ^0.1.0 range, so no module needs a new range. Everything since v0.1.0
is additive (health endpoints, Dashboard serving, production dependency
declarations, the deployment image and Compose project).
templates/module keeps 0.1.0: it is a module, versioned on its own, and its
manifest, source, and tests carry its own product version.
Mark ADR 0044 Accepted. Slices 1-4 are implemented and proven by CI's image
build and deploy smoke; slice 6 (scheduled backups and the restore
procedure) remains its open item.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Implement ADR 0044's reference deployment as a base Compose file plus
overlays, so an installation that already runs a proxy can use the base
alone.
- compose.yaml: api, postgres, object-storage, and the operator-only
migrate (profile ops, plan by default). No published port. api and
migrate run init, read-only, tmpfs /tmp, cap_drop ALL,
no-new-privileges. postgres is only on the internal data network.
- TLS everywhere from one installation-private CA: PostgreSQL TLS 1.3
only, with pg_hba rejecting every non-TLS connection, and the API on
verify-full via EKKLESIA_DATABASE_CA (PEM text). SeaweedFS serves S3
over HTTPS (ADR 0030 requires https in production), trusted by the
API through NODE_EXTRA_CA_CERTS. No Core change.
- compose.proxy.yaml + Caddyfile.example: Caddy pinned by digest, ACME
HTTP-01 (DNS-01 documented), routes /, /api/*, /display/*, /assets/*
to api and refuses everything else; the storage hostname preserves
Host. The API trusts exactly the proxy's fixed /32 on an edge network
whose dynamic range excludes the fixed addresses; Caddy overwrites
X-Forwarded-For and strips caller-supplied forwarding headers.
- compose.tunnel.yaml: cloudflared pinned by digest, token from its own
env file; Caddy honors CF-Connecting-IP only from its fixed address.
- Four per-service root-owned env files, examples with no values;
scripts/init-tls.sh (idempotent, never overwrites a key) and
scripts/gen-secrets.sh (prints only).
- CI: pnpm deploy:config in ci:verify for all three combinations, and a
deploy-smoke job (scripts/smoke-deploy.sh) proving readiness, the
Dashboard, signed storage delivery, the client-address chain, initial
owner sign-in/out, verify-full database TLS, and Doctor through the
proxy, against disposable services. tests/deploy.test.ts pins the
structural guarantees.
- ADR 0044 (still Proposed) amended to match: API-to-storage HTTPS,
DNS-01 needs a custom Caddy build and tunnel-only needs DNS-01, the
tunnel's origin TLS settings live in the Cloudflare dashboard, port
80 serves HTTP-01, and secrets are one env file per service.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A fresh production installation deadlocked. ADR 0034 gave Ekklesia a login
page and ADR 0019 gave it a single-use platform-owner bootstrap, but that
bootstrap grants ownership to a user id that already exists and has no way
to bring one into being. Public signup is disabled by default and
production cannot enable it at all, so a newly deployed instance had a
login page, no account, and no supported way to create one — only hand-
written SQL or briefly opening public registration.
pnpm identity:create-initial-owner -- --email [email protected]
creates the identity and grants it ownership, in the same shape as the
other operator commands: trusted local tooling, reachable only by someone
who already holds the database credentials, serving no HTTP route. No
/signup, /register, or /setup route is added, no bootstrap endpoint, and
no setup token; public signup stays disabled in every environment and a
test asserts a freshly bootstrapped deployment still refuses
/api/auth/sign-up/email.
The identity is created through Better Auth's own sign-up path, so
hashing, salting, normalization and the 8-128 bound are the identity
system's rather than a second implementation that could drift. Because the
server's instance refuses account creation, the command builds its own
in-process instance; nothing in apps/api imports it and the running
server's posture is untouched.
The sign-up result is verified rather than trusted. Better Auth returns a
fabricated success for an already-registered address — a user object whose
id was never persisted, database unchanged. That is correct enumeration
defence on a public endpoint and a trap here, so the identity is always
read back from the database and that read, not the response, is what the
grant uses.
An existing usable identity is adopted: ownership is granted and its
password left alone, reported as an adoption rather than a creation. This
is the recovery path for the one partial failure possible, since sign-up
commits on Better Auth's connection and the grant is a separate
transaction. A credential-less row is refused instead — making it an owner
would consume the single use on an identity nobody can authenticate as. A
password is never reset and an account is never taken over.
Whole invocations are serialized by an advisory lock on their own key, so
a race cannot leave a stray signable-in account behind; the grant's own
locked single-use transaction is unchanged. There is no --force or reset.
The password is prompted for invisibly or read from stdin, never from
argv (--password is refused with an explanation) and never from an
environment variable, so nothing about it becomes permanent deployment
configuration. The command needs DATABASE_URL and nothing else — verified
that sign-up needs no secret, base URL, or trusted origins, and that the
resulting account signs in through a separately configured server — so the
first owner can be created before TLS or DNS exist. It never migrates.
Proven end to end rather than at the database: a fresh installation is
bootstrapped, signed in through the real browser login transport with
signup disabled, its principal read, and used to open an owner-protected
resource that stays closed to anonymous callers.
Co-Authored-By: Claude Opus 5 <[email protected]>
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]>
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.
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]>