Compare commits
61
Commits
30f61094e6
...
v0.2.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
437720f228 | ||
|
|
d3148d45f7 | ||
|
|
303d455315 | ||
|
|
39f48dc7c9 | ||
|
|
9d515cd56f | ||
|
|
b8a826f8be | ||
|
|
0c9b8f7af9 | ||
|
|
5e9433d96b | ||
|
|
650932a170 | ||
|
|
2d861c6bf0 | ||
|
|
bae4ff3c6a | ||
|
|
9331a8f7df | ||
|
|
8f90871be0 | ||
|
|
85b1377fd3 | ||
|
|
4bcdb578e6 | ||
|
|
06bad2fe36 | ||
|
|
ae976ca229 | ||
|
|
f9585cc7ec | ||
|
|
6dae29412f | ||
|
|
40ef0ff802 | ||
|
|
a0e9948820 | ||
|
|
6e64b3c5f9 | ||
|
|
fa27603133 | ||
|
|
9e01679fcb | ||
|
|
227c92e35d | ||
|
|
505c9e8c0a | ||
|
|
4be046c8d0 | ||
|
|
c5a6cdaf6a | ||
|
|
c4fded6ecf | ||
|
|
9ad2d5b564 | ||
|
|
98d93cc12d | ||
|
|
e083c44e05 | ||
|
|
4919db6168 | ||
|
|
6e76293620 | ||
|
|
3b54a7f023 | ||
|
|
afaf3a853c | ||
|
|
1788d17f0a | ||
|
|
1caffc5f26 | ||
|
|
debb8df3ce | ||
|
|
98e59d4ba0 | ||
|
|
22404ea2d4 | ||
|
|
486356a01e | ||
|
|
1f30c86d4f | ||
|
|
d3c62e63bf | ||
|
|
57df2e15f2 | ||
|
|
4f1b1d68c7 | ||
|
|
9debe4e891 | ||
|
|
7ea2d7883f | ||
|
|
aaa7ebfd84 | ||
|
|
89d5d0f2da | ||
|
|
9426c83109 | ||
|
|
47e64c6f39 | ||
|
|
5803757153 | ||
|
|
44d385cd7f | ||
|
|
f53506b3f0 | ||
|
|
951453c735 | ||
|
|
99f145a80c | ||
|
|
104a5145c8 | ||
|
|
796ad4f16e | ||
|
|
b1e3244c1d | ||
|
|
85fae9e07e |
@@ -6,3 +6,7 @@
|
|||||||
*.gif binary
|
*.gif binary
|
||||||
*.webp binary
|
*.webp binary
|
||||||
*.woff2 binary
|
*.woff2 binary
|
||||||
|
|
||||||
|
# Reviewed license supplements are named by the SHA-256 of their exact bytes (ADR 0052), so Git must
|
||||||
|
# never rewrite their line endings.
|
||||||
|
packages/module-pack/license-supplements/** -text
|
||||||
|
|||||||
@@ -128,6 +128,129 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: bash scripts/ci-sweep.sh --job
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
|
package-release:
|
||||||
|
needs: verify
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
# What the gate proved, for the job that consumes its artifact to check it received exactly that.
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.release.outputs.version }}
|
||||||
|
revision: ${{ steps.release.outputs.revision }}
|
||||||
|
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
|
||||||
|
# step — including one a pull request added to this file — could read or reuse it. Nothing
|
||||||
|
# in this workflow pushes, so the credential is never needed after checkout.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Activate pinned pnpm
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# ADR 0053's package release gate: build the public package train from this commit, build it
|
||||||
|
# again from a second checkout and require byte-identical artifacts, then install exactly those
|
||||||
|
# tarballs (offline, no registry) into a module outside this repository and prove it builds,
|
||||||
|
# packages, and verifies. Nothing is published and no credential exists here. A future publish
|
||||||
|
# job must depend on this job and publish only the artifacts it proved.
|
||||||
|
- name: Build, reproduce, and consume the public package train
|
||||||
|
run: pnpm test:package-release --keep package-train
|
||||||
|
|
||||||
|
- name: Describe the proven release train
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
manifest="$release/ekklesia-package-train.json"
|
||||||
|
{
|
||||||
|
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||||
|
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||||
|
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||||
|
} >>"$GITHUB_OUTPUT"
|
||||||
|
cat "$manifest"
|
||||||
|
|
||||||
|
# The exact release artifacts, for the jobs that consume them, from a directory whose name does
|
||||||
|
# not start with a dot: the artifact actions leave hidden files out. Version 3 of the artifact
|
||||||
|
# actions: version 4 refuses any server that is not github.com, which this Gitea is not, while
|
||||||
|
# Gitea implements the protocol version 3 uses. A consumer checks the files, not the wrapper.
|
||||||
|
- name: Hand the release train on
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# The publication tool, given exactly the artifact the package-release job proved, against a
|
||||||
|
# disposable Gitea 1.27.2 (scripts/test-package-publication.sh). Never the real registry, and no
|
||||||
|
# credential: the disposable registry's accounts are created and discarded inside the job.
|
||||||
|
package-publication:
|
||||||
|
needs: package-release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
# Names this job's stacks, so its final step removes exactly these (scripts/lib/ci-project.sh).
|
||||||
|
EKKLESIA_CI_JOB_KEY: package-publication
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# A job the runner killed (cancelled, or past the runner's own time limit) never ran its
|
||||||
|
# cleanup. Anything a CI job left on this shared daemon more than three hours ago goes first.
|
||||||
|
- name: Sweep stale CI stacks from the runner
|
||||||
|
run: bash scripts/ci-sweep.sh --stale
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Activate pinned pnpm
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Receive the release train the gate proved
|
||||||
|
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
|
||||||
|
- name: Check it is exactly that train
|
||||||
|
env:
|
||||||
|
EXPECTED_MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||||
|
run: |
|
||||||
|
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
actual="$(sha256sum "$release/ekklesia-package-train.json" | cut -d' ' -f1)"
|
||||||
|
if [[ -z "$EXPECTED_MANIFEST_SHA256" || "$actual" != "$EXPECTED_MANIFEST_SHA256" ]]; then
|
||||||
|
echo "The received release manifest is not the one the package-release job proved." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
(cd "$release" && sha256sum --check --strict SHA256SUMS)
|
||||||
|
|
||||||
|
- name: Publish it to a disposable Gitea, and prove the round trip and recovery
|
||||||
|
run: bash scripts/test-package-publication.sh --release "$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
|
||||||
|
# Whatever happened above, this job's own stacks do not outlive it.
|
||||||
|
- name: Remove this job's CI stacks
|
||||||
|
if: always()
|
||||||
|
run: bash scripts/ci-sweep.sh --job
|
||||||
|
|
||||||
image-build:
|
image-build:
|
||||||
needs: verify
|
needs: verify
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
name: Publish packages
|
||||||
|
|
||||||
|
# Publishes one public @ekklesia/* package train (ADR 0053, docs/development/releases.md) to the
|
||||||
|
# npm registry: exactly the artifacts this run's package-release gate proves, and nothing else.
|
||||||
|
#
|
||||||
|
# Only ever started by hand, on `main`, by someone with write access to this repository: no pull
|
||||||
|
# request or push can start it. It needs the version-set commit of a release (`version` must equal
|
||||||
|
# the platform version there), and the repository secret EKKLESIA_PACKAGES_PUBLISH_TOKEN, a token of
|
||||||
|
# the dedicated publisher account with only the `write:package` scope. Packages are publicly
|
||||||
|
# readable (ADR 0054); publishing is what this protects.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: The package train version to publish, equal to the platform version on main.
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Never cancel a publication part way, and never run two at once. A rerun resumes safely.
|
||||||
|
concurrency:
|
||||||
|
group: publish-packages
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
# The only registry-specific setting: where the train is published.
|
||||||
|
EKKLESIA_PACKAGES_REGISTRY: https://git.zayin.net/api/packages/Zayin/npm/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
package-release:
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.release.outputs.version }}
|
||||||
|
revision: ${{ steps.release.outputs.revision }}
|
||||||
|
manifest_sha256: ${{ steps.release.outputs.manifest_sha256 }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Activate pinned pnpm
|
||||||
|
run: |
|
||||||
|
corepack enable
|
||||||
|
corepack prepare [email protected] --activate
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# The same credential-free gate as CI: build, reproduce, and consume the train.
|
||||||
|
- name: Build, reproduce, and consume the public package train
|
||||||
|
run: pnpm test:package-release --keep package-train
|
||||||
|
|
||||||
|
- name: Describe the proven release train
|
||||||
|
id: release
|
||||||
|
run: |
|
||||||
|
release="$(find package-train -mindepth 1 -maxdepth 1 -type d)"
|
||||||
|
manifest="$release/ekklesia-package-train.json"
|
||||||
|
{
|
||||||
|
echo "version=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$manifest")"
|
||||||
|
echo "revision=$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).source.revision' "$manifest")"
|
||||||
|
echo "manifest_sha256=$(sha256sum "$manifest" | cut -d' ' -f1)"
|
||||||
|
} >>"$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Artifact actions version 3: version 4 refuses any server that is not github.com. The directory
|
||||||
|
# name must not start with a dot: the artifact actions leave hidden files out.
|
||||||
|
- name: Hand the release train on
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: package-release
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
# Only for the publication tool itself: nothing is built or installed in this job.
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
with:
|
||||||
|
node-version-file: .node-version
|
||||||
|
|
||||||
|
- name: Receive the release train the gate proved
|
||||||
|
uses: actions/download-artifact@ad191675b41f6a5b46da9a048cb6893812da158b # v3.1.0-node20
|
||||||
|
with:
|
||||||
|
name: package-train
|
||||||
|
path: package-train/
|
||||||
|
|
||||||
|
# The tool validates the whole train against its manifest, that manifest against the gate's,
|
||||||
|
# its version against the request, and its revision against this commit, before it sends
|
||||||
|
# anything; then publishes, verifies every package byte for byte, and moves `latest` last.
|
||||||
|
- name: Publish the exact release train
|
||||||
|
env:
|
||||||
|
EKKLESIA_PACKAGES_PUBLISH_TOKEN: ${{ secrets.EKKLESIA_PACKAGES_PUBLISH_TOKEN }}
|
||||||
|
TRAIN_VERSION: ${{ inputs.version }}
|
||||||
|
TRAIN_REVISION: ${{ github.sha }}
|
||||||
|
MANIFEST_SHA256: ${{ needs.package-release.outputs.manifest_sha256 }}
|
||||||
|
run: |
|
||||||
|
node scripts/publish-package-train.ts \
|
||||||
|
--release "$(find package-train -mindepth 1 -maxdepth 1 -type d)" \
|
||||||
|
--registry "$EKKLESIA_PACKAGES_REGISTRY" \
|
||||||
|
--version "$TRAIN_VERSION" \
|
||||||
|
--revision "$TRAIN_REVISION" \
|
||||||
|
--manifest-sha256 "$MANIFEST_SHA256" \
|
||||||
|
--record publication-record.json \
|
||||||
|
--require-public-read
|
||||||
|
|
||||||
|
- name: Keep the publication record
|
||||||
|
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de # v3.2.2-node20
|
||||||
|
with:
|
||||||
|
name: package-train-publication-record
|
||||||
|
path: publication-record.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 90
|
||||||
+12
@@ -1,4 +1,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
# pnpm's content-addressed package store. pnpm keeps it beside node_modules on the same file system,
|
||||||
|
# so when the checkout is its own mount (a CI runner's workspace volume, a bind-mounted container),
|
||||||
|
# `pnpm install` creates it here, at the checkout's root. A package cache, never source.
|
||||||
|
.pnpm-store/
|
||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
dist/
|
dist/
|
||||||
@@ -16,3 +20,11 @@ generated/*
|
|||||||
|
|
||||||
core/.identity-schema-reference.ts
|
core/.identity-schema-reference.ts
|
||||||
core/.identity-schema-check-*
|
core/.identity-schema-check-*
|
||||||
|
|
||||||
|
# The packager tests copy fixture modules here (packages/module-pack/tests/support/fixture-module.ts).
|
||||||
|
packages/module-pack/.fixtures/
|
||||||
|
|
||||||
|
# `pnpm packages:release` writes package release trains here (scripts/package-release.ts).
|
||||||
|
.package-release/
|
||||||
|
# CI keeps the proven release here to hand it on as a workflow artifact (scripts/test-package-release.sh --keep).
|
||||||
|
/package-train/
|
||||||
|
|||||||
@@ -6,27 +6,60 @@ contents, because restated details drift.
|
|||||||
|
|
||||||
## What this repository is
|
## What this repository is
|
||||||
|
|
||||||
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
|
Ekklesia is a modular, self-hosted church platform. **This repository is the platform** — Core, the
|
||||||
monorepo:
|
API, the Dashboard and display shell, and the public `@ekklesia/*` module-development packages — built
|
||||||
|
as a strictly layered pnpm/TypeScript monorepo:
|
||||||
|
|
||||||
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
|
||||||
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
|
execution, module discovery/lifecycle, devices, and other feature-agnostic services. Core never
|
||||||
feature module, a surface, or an app.
|
imports a feature module, a surface, or an app.
|
||||||
- **Modules** (`modules/*`, template at `templates/module`) are independently versioned product
|
- **Modules** are independently versioned product packages. First-party modules live in **their own
|
||||||
packages. They own their own data and migrations, declare capabilities/permissions through
|
repositories** (Signage is `Zayin/Signage`), build against the published `@ekklesia/*` packages
|
||||||
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never import Core, another module, or a
|
(ADR 0053), and reach an installation as a Module Package (ADR 0049) installed into module storage
|
||||||
surface directly. Per ADR 0021 a module owns its UI; manifest v7 permits independently optional
|
outside the image (ADR 0048). No module source lives in this repository; `templates/module` is the
|
||||||
primary and full-viewport display artifacts, authored in React against the same role-neutral
|
example, and `modules/` is only an optional, ignored local installed-modules directory. A module owns
|
||||||
`@ekklesia/module-ui-sdk` ABI. Ekklesia mounts each through a separate surface role, while
|
its data, migrations, and **its own domain UI** (ADR 0021), declares capabilities/permissions through
|
||||||
`pnpm boundaries` still rejects every non-React UI framework and non-`.tsx` presentation source.
|
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and never imports Core, another module, or a
|
||||||
|
surface. Manifest v8 permits independently optional primary and full-viewport display artifacts,
|
||||||
|
authored in React against the role-neutral `@ekklesia/module-ui-sdk` ABI; database access is the
|
||||||
|
Module Database ABI v1 (ADR 0050).
|
||||||
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
- **Surfaces** (`surfaces/*`) own the containing application shell, navigation, and every
|
||||||
presentation concern a module does not. They consume public API/client contracts only, never Core
|
presentation concern a module does not. The shell **hosts** module UI through the generic loader;
|
||||||
or module internals directly.
|
it never rebuilds a module's domain screens. Surfaces consume public API/client contracts only.
|
||||||
|
|
||||||
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
|
||||||
documented. Read `docs/architecture/repository-boundaries.md` and
|
documented. Read `docs/architecture/repository-boundaries.md` and
|
||||||
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
|
||||||
|
|
||||||
|
## Product direction lives beside this repository
|
||||||
|
|
||||||
|
The current roadmap and product/interface design contract are `ROADMAP.md` and `DESIGN.md` in the
|
||||||
|
parent development directory (`../ROADMAP.md` and `../DESIGN.md` from a checkout in the standard
|
||||||
|
`~/development/ekklesia/` layout). They are deliberately **not** committed here; do not copy them into
|
||||||
|
this repository to make them available. They are authoritative for product direction: read the
|
||||||
|
relevant stages and sections before designing a change. If this repository's documentation disagrees
|
||||||
|
with them, flag the conflict, correct the repository-local documentation where appropriate, and never
|
||||||
|
silently follow the stale text. Architectural decisions still change only through ADRs (below).
|
||||||
|
|
||||||
|
Rules from those documents that every change here must respect:
|
||||||
|
|
||||||
|
- **Ownership.** Modules own their domain data, behaviour, and UI. Shared resources — displays and
|
||||||
|
devices, accounts and identity, roles and authorization, branding, mail, push, storage providers,
|
||||||
|
and module installation/lifecycle — belong to the platform, and modules consume them.
|
||||||
|
- **Branding.** Normal user-facing UI never hard-codes "Ekklesia"; product identity comes from
|
||||||
|
installation branding (ADR 0046). Package names, diagnostics, logs, and developer tooling may.
|
||||||
|
Existing hard-coded shell strings are known debt for Stage 1, not a pattern to copy.
|
||||||
|
- **Visual direction.** Simple, minimal, and calm; responsive for phone, tablet, and desktop; light,
|
||||||
|
dark, and system modes. Normal UI colours come only from the shared design tokens
|
||||||
|
(`@ekklesia/design-tokens`, ADR 0039) — never a hard-coded colour.
|
||||||
|
- **No browser-native dialogs.** `window.alert`, `window.confirm`, and `window.prompt` are prohibited
|
||||||
|
in product UI. Destructive actions use in-product confirmation proportional to their impact.
|
||||||
|
- **Displays are transitional.** Displays are platform-owned. Today's device pairing (the Dashboard
|
||||||
|
sidebar's *Display devices* panel) and Signage's own display roster are a transitional
|
||||||
|
implementation that Stage 1 migrates to **Settings → Displays**. Fix defects in them, but do not
|
||||||
|
build the Stage 1 Settings, global Displays, theme editor, module installer, or responsive shell
|
||||||
|
redesign early, and keep existing paired displays working across any change.
|
||||||
|
|
||||||
## ADRs are authoritative
|
## ADRs are authoritative
|
||||||
|
|
||||||
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
|
||||||
@@ -43,6 +76,7 @@ At minimum, read the current versions of:
|
|||||||
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
|
||||||
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
|
||||||
- `README.md` (current platform status — what is and is not implemented)
|
- `README.md` (current platform status — what is and is not implemented)
|
||||||
|
- `../ROADMAP.md` and `../DESIGN.md` (current product direction; see above)
|
||||||
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
|
||||||
- `CONTRIBUTING.md`
|
- `CONTRIBUTING.md`
|
||||||
|
|
||||||
@@ -52,8 +86,16 @@ your own memory of a prior session** — treat stale assumptions as a bug to cat
|
|||||||
|
|
||||||
## Working conventions
|
## Working conventions
|
||||||
|
|
||||||
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
|
- The canonical repositories are on the church Gitea under the `Zayin` organization:
|
||||||
change branch-protection settings.
|
`ssh://[email protected]:2222/Zayin/Ekklesia.git` (this one), `Zayin/Signage.git`, and
|
||||||
|
`Zayin/Classroom_Desktop.git`. A local clone may be behind the server: `git fetch --prune --tags`
|
||||||
|
and branch from `origin/main`, never from whatever is checked out.
|
||||||
|
- One branch per PR, off an up-to-date `origin/main`. Never commit or push directly to `main`, never
|
||||||
|
merge your own PR, and never change branch-protection settings.
|
||||||
|
- Never create or push a release tag. Release tags belong to the human maintainer.
|
||||||
|
- A change that needs both a platform contract change and a module change is two PRs: Core first, on
|
||||||
|
its own branch, with the compatibility boundary stated explicitly; then the module. Do not copy
|
||||||
|
platform logic into a module to avoid making the correct Core change.
|
||||||
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
|
||||||
burying them in an implementation.
|
burying them in an implementation.
|
||||||
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
- Add or update tests when you change a contract or architectural rule. New behavior needs new
|
||||||
@@ -78,6 +120,13 @@ ever disagree:
|
|||||||
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
|
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
|
||||||
planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal
|
planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal
|
||||||
resolution, sign-out). Requires Docker. Run this for any change touching either area.
|
resolution, sign-out). Requires Docker. Run this for any change touching either area.
|
||||||
|
- `pnpm test:package-release` — builds the public `@ekklesia/*` package train, proves it
|
||||||
|
reproducible, and proves a standalone module consumes exactly those tarballs. Publishes nothing.
|
||||||
|
Run it for any change to the public packages or their release tooling.
|
||||||
|
- `pnpm test:package-publication` — publishes a release train to a disposable Gitea (Docker) and
|
||||||
|
proves the publication tool. Never the real registry. The real publication is the manual
|
||||||
|
`Publish packages` workflow only; never run `scripts/publish-package-train.ts` against the real
|
||||||
|
registry without the user's explicit intent.
|
||||||
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
|
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
|
||||||
verify` or the integration suite.
|
verify` or the integration suite.
|
||||||
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
|
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
|
||||||
|
|||||||
@@ -48,10 +48,34 @@ migration history is retained and reported, and blocks no other migration. A mod
|
|||||||
as one **Ekklesia Module Package v1**
|
as one **Ekklesia Module Package v1**
|
||||||
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
|
([ADR 0049](docs/architecture/decisions/0049-module-package-v1.md)): a deterministic `.tar.gz` and
|
||||||
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
|
its SHA-256, produced by the canonical packager (`@ekklesia/module-pack`) with the module's backend
|
||||||
bundled, and checked by Core's verifier before it is installed. Only the portable `any` target exists
|
bundled, and checked by Core's verifier before it is installed. Two targets exist:
|
||||||
so far. ABI-v1 packages may bundle a module-owned Drizzle version without coupling it to Core;
|
- the portable `any` target, for pure-JavaScript modules;
|
||||||
legacy-v8 packages retain their transitional exact-version check. Purge, native-target packages,
|
- `linux-x64-glibc`, for modules with a native dependency such as `sharp`. Its native runtime
|
||||||
signing, and any installer or marketplace do not exist yet.
|
packages are carried as real files under the package's `node_modules`, and the verifier checks the
|
||||||
|
host before anything is loaded.
|
||||||
|
|
||||||
|
Every package records the module's validated SPDX expression and carries its explicitly
|
||||||
|
supplied `LICENSE` bytes; dependency material remains separate in `THIRD_PARTY_NOTICES`. ABI-v1
|
||||||
|
packages may bundle a module-owned Drizzle version without coupling it to Core;
|
||||||
|
legacy-v8 packages retain their transitional exact-version check. Where a redistributed dependency
|
||||||
|
omits licence material, the packager may add reviewed licence supplements
|
||||||
|
([ADR 0052](docs/architecture/decisions/0052-reviewed-license-supplements.md)). A native package that
|
||||||
|
redistributes LGPL or MPL code is distributed publicly only with its corresponding-source artifact,
|
||||||
|
which `ekklesia-corresponding-source` builds and gates
|
||||||
|
([ADR 0055](docs/architecture/decisions/0055-corresponding-source-artifacts.md)). Purge, other
|
||||||
|
operating-system or CPU targets, signing, and any installer or marketplace do not exist yet.
|
||||||
|
|
||||||
|
A module can be built in its own repository from Ekklesia's public module-development packages
|
||||||
|
([ADR 0053](docs/architecture/decisions/0053-standalone-module-development-packages.md)):
|
||||||
|
a curated `@ekklesia/module-sdk`, `@ekklesia/module-database`, the UI packages, and the build and
|
||||||
|
packaging tools. They are released as one coordinated train of tarballs with a release manifest,
|
||||||
|
and CI proves on every run that two builds of a commit are byte-identical and that a module outside
|
||||||
|
the repository installs, builds, packages, and verifies from those artifacts alone. A manual
|
||||||
|
workflow, the only way to publish, publishes exactly those artifacts to the npm registry and
|
||||||
|
verifies every package byte for byte. Anyone may read and install the packages before launch
|
||||||
|
([ADR 0054](docs/architecture/decisions/0054-public-package-reads-before-launch.md)). The first train
|
||||||
|
published will be the next platform release
|
||||||
|
([Releases and the public package train](docs/development/releases.md)).
|
||||||
|
|
||||||
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
|
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
|
||||||
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/api",
|
"name": "@ekklesia/api",
|
||||||
"version": "0.1.2",
|
"version": "0.2.5",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+9
-3
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
isAuthenticatedPrincipal,
|
isAuthenticatedPrincipal,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
type ModuleSecretSource,
|
type ModuleSecretSource,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -13,6 +12,7 @@ import type { DashboardStore } from "@ekklesia/core/dashboard";
|
|||||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
import type {
|
import type {
|
||||||
|
AnyModuleRuntimeServices,
|
||||||
DiscoveredModule,
|
DiscoveredModule,
|
||||||
ModuleConfigurationLookup,
|
ModuleConfigurationLookup,
|
||||||
ModuleConfigurationSettings,
|
ModuleConfigurationSettings,
|
||||||
@@ -31,7 +31,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
|
|||||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||||
import { createDeviceRoutes } from "./device-routes.js";
|
import { createDeviceRoutes, renewDeviceSessionCookie } from "./device-routes.js";
|
||||||
import type { DisplayHostAssets } from "./display-host-assets.js";
|
import type { DisplayHostAssets } from "./display-host-assets.js";
|
||||||
import { createGrantsRoutes } from "./grants-routes.js";
|
import { createGrantsRoutes } from "./grants-routes.js";
|
||||||
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
import { type ApiHealthOptions, createHealthRoutes } from "./health-routes.js";
|
||||||
@@ -66,7 +66,7 @@ export interface CreateApiAppOptions {
|
|||||||
/** The same per-module service lookup the caller's started module runtime used for every
|
/** The same per-module service lookup the caller's started module runtime used for every
|
||||||
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's
|
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — each handler receives its module's
|
||||||
* reference-identical service object, never a fresh facade or executor built per request. */
|
* reference-identical service object, never a fresh facade or executor built per request. */
|
||||||
readonly moduleServices: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
|
readonly moduleServices: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||||
/** The platform's module UI publication policy — always the one Core builds from the exact
|
/** The platform's module UI publication policy — always the one Core builds from the exact
|
||||||
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
|
* discovered inventory this runtime started (`createModuleUiPublicationRegistry`,
|
||||||
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
|
* `apps/api/src/server.ts`). Required rather than defaulted, for the same reason `moduleRoutes`
|
||||||
@@ -407,6 +407,12 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
|||||||
registry: options.authorization.registry,
|
registry: options.authorization.registry,
|
||||||
runtime,
|
runtime,
|
||||||
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
...(options.displayHostAssets ? { assets: options.displayHostAssets } : {}),
|
||||||
|
...(options.devices
|
||||||
|
? {
|
||||||
|
renewDeviceCredential: (c: Context) =>
|
||||||
|
renewDeviceSessionCookie(c, options.devices?.secureCookies ?? true),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.devices) {
|
if (options.devices) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ModuleDatabase, ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
type AuthorizationDrizzleDatabase,
|
type AuthorizationDrizzleDatabase,
|
||||||
createAuthorizationDrizzleView,
|
createAuthorizationDrizzleView,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
createModuleDatabaseView,
|
createModuleDatabaseView,
|
||||||
createModuleLifecycleView,
|
createModuleLifecycleView,
|
||||||
type ModuleConfigurationDrizzleDatabase,
|
type ModuleConfigurationDrizzleDatabase,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleLifecycleDrizzleDatabase,
|
type ModuleLifecycleDrizzleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function readString(body: Record<string, unknown>, key: string, max: number): st
|
|||||||
*/
|
*/
|
||||||
function writeDeviceCookie(
|
function writeDeviceCookie(
|
||||||
c: Context,
|
c: Context,
|
||||||
options: DeviceRoutesOptions,
|
options: Pick<DeviceRoutesOptions, "secureCookies">,
|
||||||
name: string,
|
name: string,
|
||||||
value: string,
|
value: string,
|
||||||
maxAgeSeconds: number,
|
maxAgeSeconds: number,
|
||||||
@@ -106,6 +106,27 @@ function writeDeviceCookie(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-issues the device cookie a request just authenticated with, with a fresh lifetime (ADR 0056).
|
||||||
|
*
|
||||||
|
* The value is unchanged — no rotation — so a renewal lost in transit costs nothing: the screen still
|
||||||
|
* holds a working credential. Only the browser's own expiry is pushed forward, matching the
|
||||||
|
* server's sliding window. Called only after the request resolved to a live, unrevoked device.
|
||||||
|
*/
|
||||||
|
export function renewDeviceSessionCookie(c: Context, secureCookies: boolean): void {
|
||||||
|
const credential = getCookie(c, DEVICE_SESSION_COOKIE);
|
||||||
|
if (credential === undefined || credential.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeDeviceCookie(
|
||||||
|
c,
|
||||||
|
{ secureCookies },
|
||||||
|
DEVICE_SESSION_COOKIE,
|
||||||
|
credential,
|
||||||
|
Math.floor(DEVICE_CREDENTIAL_TTL_MS / 1000),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
export function createDeviceRoutes(options: DeviceRoutesOptions): Hono {
|
||||||
const routes = new HonoApp();
|
const routes = new HonoApp();
|
||||||
const now = options.now ?? ((): Date => new Date());
|
const now = options.now ?? ((): Date => new Date());
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ export interface ModuleDisplayRoutesOptions extends AuthorizationMiddlewareOptio
|
|||||||
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
/** Omitted only when the registry contains no display entrypoints. Production reads the exact
|
||||||
* surface-owned files before binding and supplies their frozen contents here. */
|
* surface-owned files before binding and supplies their frozen contents here. */
|
||||||
readonly assets?: DisplayHostAssets;
|
readonly assets?: DisplayHostAssets;
|
||||||
|
/** Re-issues a paired device's cookie with a fresh lifetime (ADR 0056). Composed only when the
|
||||||
|
* deployment has device support; called only for a request that resolved to a device. */
|
||||||
|
readonly renewDeviceCredential?: (c: Context) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A composition error, detected before any display route is registered. Carries no module id,
|
/** A composition error, detected before any display route is registered. Carries no module id,
|
||||||
@@ -199,6 +202,11 @@ function mountDisplay(
|
|||||||
if (!viewer) {
|
if (!viewer) {
|
||||||
return displayFailure(c);
|
return displayFailure(c);
|
||||||
}
|
}
|
||||||
|
// Every screen asks for this on start, on reconnect, and periodically while running, so it
|
||||||
|
// is where an in-use credential's browser expiry is pushed forward.
|
||||||
|
if (principal.kind === "device") {
|
||||||
|
options.renewDeviceCredential?.(c);
|
||||||
|
}
|
||||||
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
return c.json(await describeDisplay(options, principal, display, viewer), 200);
|
||||||
} catch {
|
} catch {
|
||||||
return displayFailure(c);
|
return displayFailure(c);
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
EMPTY_MODULE_CONFIGURATION,
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
EMPTY_MODULE_STORAGE,
|
EMPTY_MODULE_STORAGE,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleDatabaseExecutor,
|
type ModuleDatabaseExecutor,
|
||||||
type ModuleId,
|
type ModuleId,
|
||||||
type ModuleRouteHandlerContext,
|
type ModuleRouteHandlerContext,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import type {
|
import type {
|
||||||
|
AnyModuleRuntimeServices,
|
||||||
ModuleConfigurationLookup,
|
ModuleConfigurationLookup,
|
||||||
|
ModuleDatabase,
|
||||||
ModuleRealtimeRegistry,
|
ModuleRealtimeRegistry,
|
||||||
ModuleRuntimeServicesLookup,
|
ModuleRuntimeServicesLookup,
|
||||||
ModuleStorageLookup,
|
ModuleStorageLookup,
|
||||||
@@ -40,7 +40,7 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
|
|||||||
* received for this API runtime — never a fresh one built per request, and never touched for a
|
* received for this API runtime — never a fresh one built per request, and never touched for a
|
||||||
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
* denied, trusted-origin-rejected, or `OPTIONS` request, since a handler is only ever invoked
|
||||||
* after authorization succeeds. */
|
* after authorization succeeds. */
|
||||||
readonly services: ModuleRuntimeServicesLookup | ModuleRuntimeServices;
|
readonly services: ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||||
/**
|
/**
|
||||||
* Per-module frozen configuration snapshots — the exact map the module runtime started from
|
* Per-module frozen configuration snapshots — the exact map the module runtime started from
|
||||||
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
|
* (ADR 0029), so a handler and that module's `start`/`stop` hooks receive the identical object
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import type { Socket } from "node:net";
|
import type { Socket } from "node:net";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import {
|
import { EMPTY_MODULE_SECRET_SOURCE, type ModuleSecretSource } from "@ekklesia/contracts";
|
||||||
type AnyModuleRuntimeServices,
|
|
||||||
EMPTY_MODULE_SECRET_SOURCE,
|
|
||||||
type ModuleSecretSource,
|
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
import {
|
import {
|
||||||
createAuthorizationEvaluator,
|
createAuthorizationEvaluator,
|
||||||
createAuthorizationStore,
|
createAuthorizationStore,
|
||||||
@@ -21,6 +17,7 @@ import {
|
|||||||
} from "@ekklesia/core/identity";
|
} from "@ekklesia/core/identity";
|
||||||
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
|
||||||
import {
|
import {
|
||||||
|
type AnyModuleRuntimeServices,
|
||||||
createModuleConfigurationStore,
|
createModuleConfigurationStore,
|
||||||
createModuleLifecycleStore,
|
createModuleLifecycleStore,
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
|
|||||||
@@ -221,9 +221,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory: emptyModulesDirectory,
|
modulesDirectory: emptyModulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
|
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
|
||||||
@@ -339,7 +339,7 @@ async function createGatedRouteModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 8,
|
manifestVersion: 8,
|
||||||
@@ -390,7 +390,7 @@ async function createFixtureModule(
|
|||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||||
permissions: [],
|
permissions: [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
...manifestOverrides,
|
...manifestOverrides,
|
||||||
@@ -521,7 +521,7 @@ async function createUiModule(
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 8,
|
manifestVersion: 8,
|
||||||
@@ -2592,9 +2592,9 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
|||||||
const report = await diagnoseEkklesia({
|
const report = await diagnoseEkklesia({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
runtimeRange: ">=0.0.0",
|
runtimeRange: ">=0.0.0",
|
||||||
runtimeVersion: process.versions.node,
|
runtimeVersion: process.versions.node,
|
||||||
@@ -3906,9 +3906,9 @@ describe.sequential("live API — module lifecycle governs what actually runs",
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
expect(aggregate.discoveredModules.map((module) => module.manifest.id)).toEqual(["calendar"]);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
moduleIdSchema,
|
moduleIdSchema,
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
@@ -20,6 +18,8 @@ import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleUiPublicationRegistry,
|
type ModuleUiPublicationRegistry,
|
||||||
StagedModuleRealtimeRegistry,
|
StagedModuleRealtimeRegistry,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
@@ -48,9 +48,9 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise `createApiApp`'s own wiring,
|
||||||
* never real database behavior, so no PostgreSQL is required. */
|
* never real database behavior, so no PostgreSQL is required. */
|
||||||
function fakeModuleServices(): ModuleRuntimeServices {
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
const database = Object.freeze({
|
const database = Object.freeze({
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
execute: vi.fn(),
|
execute: vi.fn(),
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -16,6 +14,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ function fakeStore(): AuthorizationStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeModuleServices(): ModuleRuntimeServices {
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +18,8 @@ import {
|
|||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -147,7 +147,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
|||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: Object.freeze({
|
moduleServices: Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
}) as ModuleRuntimeServices,
|
}) as LegacyModuleRuntimeServices,
|
||||||
moduleUi: createModuleUiPublicationRegistry(modules),
|
moduleUi: createModuleUiPublicationRegistry(modules),
|
||||||
moduleLifecycle: {
|
moduleLifecycle: {
|
||||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ async function createDisplayModuleDirectory(): Promise<string> {
|
|||||||
writeFile(
|
writeFile(
|
||||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
display: { artifact: "./dist/ui/display.js", permission: displayPermission },
|
||||||
server: "./dist/server/index.js",
|
server: "./dist/server/index.js",
|
||||||
@@ -782,6 +782,127 @@ describe.sequential("display device pairing, end to end", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR 0056: a credential lasts while the screen is in use, and dies 180 days after it was last
|
||||||
|
* seen. Ages are set directly in the database, exactly as months of real use would leave them.
|
||||||
|
*/
|
||||||
|
describe("credential lifetime", () => {
|
||||||
|
const ttlSeconds = 180 * 24 * 60 * 60;
|
||||||
|
const descriptor = "/api/v1/module-displays/announcements";
|
||||||
|
|
||||||
|
function renewal(response: Response): string | undefined {
|
||||||
|
return setCookieFor(response, "ekklesia_device_session");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps a screen that is in use paired long past 180 days, renewing its cookie", async () => {
|
||||||
|
const running = await platform("renewal");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const credential = device.header().replace(/^.*ekklesia_device_session=/u, "");
|
||||||
|
// Paired 200 days ago, and seen yesterday: a screen that has simply been working.
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '200 days', last_seen_at = now() - interval '1 day'`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
// The same credential, re-issued with a full lifetime and the same protections.
|
||||||
|
const header = renewal(response);
|
||||||
|
expect(header).toBeDefined();
|
||||||
|
expect(header).toContain(`ekklesia_device_session=${credential};`);
|
||||||
|
expect(header).toMatch(new RegExp(`Max-Age=${ttlSeconds}(;|$)`, "u"));
|
||||||
|
expect(header).toMatch(/HttpOnly/u);
|
||||||
|
expect(header).toMatch(/SameSite=Strict/u);
|
||||||
|
expect(header).toMatch(/Path=\//u);
|
||||||
|
expect(header).not.toMatch(/Domain=/iu);
|
||||||
|
|
||||||
|
// Using it moved the server's window forward too.
|
||||||
|
const [row] = await query<{ recent: boolean }>(
|
||||||
|
running.url,
|
||||||
|
`select last_seen_at > now() - interval '1 minute' as recent from ekklesia_core_device`,
|
||||||
|
);
|
||||||
|
expect(row?.recent).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a credential paired under the fixed 180-day rule working once it is renewed", async () => {
|
||||||
|
const running = await platform("legacy");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
// A production screen paired 179 days ago that checked in this morning: under the old
|
||||||
|
// rule it had one day left. It must carry straight on.
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '179 days', last_seen_at = now() - interval '2 hours'`,
|
||||||
|
);
|
||||||
|
expect((await device.request(descriptor)).status).toBe(200);
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '190 days'`,
|
||||||
|
);
|
||||||
|
expect((await device.request(descriptor)).status).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an abandoned screen stop being an identity 180 days after it was last seen", async () => {
|
||||||
|
const running = await platform("abandoned");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
await query(
|
||||||
|
running.url,
|
||||||
|
`update ekklesia_core_device set created_at = now() - interval '400 days', last_seen_at = now() - interval '181 days'`,
|
||||||
|
);
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
// Anonymous, exactly as revoked or wrong credentials are: the screen shows a pairing code.
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never renews a revoked screen", async () => {
|
||||||
|
const running = await platform("revoked-renewal");
|
||||||
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
try {
|
||||||
|
await pairDevice(running, device);
|
||||||
|
const listed = (await (
|
||||||
|
await adminRequest(running.origin, running.ownerCookie, "/api/v1/devices")
|
||||||
|
).json()) as { devices: Array<{ id: string }> };
|
||||||
|
await adminRequest(
|
||||||
|
running.origin,
|
||||||
|
running.ownerCookie,
|
||||||
|
`/api/v1/devices/${listed.devices[0]?.id}/revoke`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const response = await device.request(descriptor);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hand a device cookie to a person viewing the display", async () => {
|
||||||
|
const running = await platform("human-renewal");
|
||||||
|
try {
|
||||||
|
const response = await adminRequest(running.origin, running.ownerCookie, descriptor);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(renewal(response)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await running.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
it("keeps a human administrator's own session unaffected by a device cookie", async () => {
|
||||||
const running = await platform("mixed");
|
const running = await platform("mixed");
|
||||||
const device = new DeviceBrowser(running.origin);
|
const device = new DeviceBrowser(running.origin);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
@@ -15,6 +13,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createApiApp } from "../src/app.js";
|
import { createApiApp } from "../src/app.js";
|
||||||
@@ -56,7 +56,7 @@ function fakeStore(): AuthorizationStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeModuleServices(): ModuleRuntimeServices {
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ async function createFixtureModule(
|
|||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||||
permissions: [],
|
permissions: [],
|
||||||
entrypoints: { server: "./dist/server/index.js" },
|
entrypoints: { server: "./dist/server/index.js" },
|
||||||
...manifestOverrides,
|
...manifestOverrides,
|
||||||
@@ -267,7 +267,7 @@ function fakeDiscoveredModule(
|
|||||||
): DiscoveredModule {
|
): DiscoveredModule {
|
||||||
const manifest = parseModuleManifest({
|
const manifest = parseModuleManifest({
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
|
||||||
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
entrypoints: { server: "./dist/does-not-exist/index.js" },
|
||||||
id,
|
id,
|
||||||
manifestVersion: 8,
|
manifestVersion: 8,
|
||||||
@@ -1435,9 +1435,9 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
|
|||||||
const recorder = createEventRecorder();
|
const recorder = createEventRecorder();
|
||||||
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
|
||||||
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
});
|
});
|
||||||
mockPoolAndAuth();
|
mockPoolAndAuth();
|
||||||
mockSuccessfulServe();
|
mockSuccessfulServe();
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import {
|
|||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleConfigurationDeclarationInput,
|
type ModuleConfigurationDeclarationInput,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -22,10 +20,12 @@ import {
|
|||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
type ModuleConfigurationRecord,
|
type ModuleConfigurationRecord,
|
||||||
type ModuleConfigurationStore,
|
type ModuleConfigurationStore,
|
||||||
ModuleConfigurationStoreError,
|
ModuleConfigurationStoreError,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type ModuleDatabase,
|
||||||
resolveModuleEligibility,
|
resolveModuleEligibility,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
@@ -213,7 +213,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: Object.freeze({
|
moduleServices: Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
}) as ModuleRuntimeServices,
|
}) as LegacyModuleRuntimeServices,
|
||||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -212,9 +212,9 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory: emptyModulesDirectory,
|
modulesDirectory: emptyModulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||||
@@ -241,9 +241,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
|||||||
const aggregate = await discoverAllMigrations({
|
const aggregate = await discoverAllMigrations({
|
||||||
coreMigrationsDirectory,
|
coreMigrationsDirectory,
|
||||||
moduleDiscovery: {
|
moduleDiscovery: {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory: fixtureModulesDirectory,
|
modulesDirectory: fixtureModulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
// Core migrations aggregate first, exactly once each, and always before any module migration.
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +19,8 @@ import {
|
|||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -155,7 +155,7 @@ interface Installation {
|
|||||||
readonly modules: readonly DiscoveredModule[];
|
readonly modules: readonly DiscoveredModule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fakeServices(): ModuleRuntimeServices {
|
function fakeServices(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRouteHandlerContext,
|
type ModuleRouteHandlerContext,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
moduleRoutesOverlap,
|
moduleRoutesOverlap,
|
||||||
type PermissionId,
|
type PermissionId,
|
||||||
parsePermissionId,
|
parsePermissionId,
|
||||||
@@ -20,6 +18,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleRuntimeServicesLookup,
|
type ModuleRuntimeServicesLookup,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
@@ -74,11 +74,11 @@ function moduleRoute(
|
|||||||
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
/** A small typed fake `LegacyModuleRuntimeServices` — these tests exercise route mounting,
|
||||||
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
||||||
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
||||||
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
||||||
function fakeModuleServices(): ModuleRuntimeServices {
|
function fakeModuleServices(): LegacyModuleRuntimeServices {
|
||||||
const database = Object.freeze({
|
const database = Object.freeze({
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
execute: vi.fn(),
|
execute: vi.fn(),
|
||||||
@@ -92,7 +92,7 @@ function fakeModuleServices(): ModuleRuntimeServices {
|
|||||||
return Object.freeze({ database });
|
return Object.freeze({ database });
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
|
function expectDatabaseFakeUntouched(services: LegacyModuleRuntimeServices): void {
|
||||||
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
||||||
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
||||||
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
||||||
@@ -108,7 +108,7 @@ function buildModuleApp(options: {
|
|||||||
readonly registry: PermissionRegistry;
|
readonly registry: PermissionRegistry;
|
||||||
readonly routes: readonly RegisteredModuleHttpRoute[];
|
readonly routes: readonly RegisteredModuleHttpRoute[];
|
||||||
readonly runtime?: EkklesiaIdentityRuntime;
|
readonly runtime?: EkklesiaIdentityRuntime;
|
||||||
readonly services?: ModuleRuntimeServices | ModuleRuntimeServicesLookup;
|
readonly services?: LegacyModuleRuntimeServices | ModuleRuntimeServicesLookup;
|
||||||
readonly trustedOrigins?: readonly string[];
|
readonly trustedOrigins?: readonly string[];
|
||||||
}) {
|
}) {
|
||||||
const evaluator = options.evaluator ?? {
|
const evaluator = options.evaluator ?? {
|
||||||
@@ -539,7 +539,7 @@ describe("module route authorization", () => {
|
|||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
|
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not invoke the handler when principal resolution fails", async () => {
|
it("does not invoke the handler when principal resolution fails", async () => {
|
||||||
@@ -668,7 +668,7 @@ describe("module route trusted-origin enforcement", () => {
|
|||||||
}
|
}
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
|
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
||||||
@@ -797,7 +797,7 @@ describe("module route CORS", () => {
|
|||||||
|
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||||
expectDatabaseFakeUntouched(services as ModuleRuntimeServices);
|
expectDatabaseFakeUntouched(services as LegacyModuleRuntimeServices);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never reflects an untrusted origin for a module route", async () => {
|
it("never reflects an untrusted origin for a module route", async () => {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +18,8 @@ import {
|
|||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleLifecycleStore,
|
type ModuleLifecycleStore,
|
||||||
ModuleLifecycleStoreError,
|
ModuleLifecycleStoreError,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
@@ -189,7 +189,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
|||||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
moduleServices: Object.freeze({
|
moduleServices: Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
}) as ModuleRuntimeServices,
|
}) as LegacyModuleRuntimeServices,
|
||||||
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
moduleUi: createModuleUiPublicationRegistry(eligibility.eligibleModules),
|
||||||
runtime,
|
runtime,
|
||||||
trustedOrigins: [trustedOrigin],
|
trustedOrigins: [trustedOrigin],
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import path from "node:path";
|
|||||||
import {
|
import {
|
||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRealtimePublisher,
|
type ModuleRealtimePublisher,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +16,8 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|||||||
import {
|
import {
|
||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
type ModuleRealtimeRegistry,
|
type ModuleRealtimeRegistry,
|
||||||
type ModuleRealtimeSubscription,
|
type ModuleRealtimeSubscription,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
@@ -226,7 +226,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
|||||||
return { directory, symbol };
|
return { directory, symbol };
|
||||||
}
|
}
|
||||||
|
|
||||||
function services(): ModuleRuntimeServices {
|
function services(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +16,8 @@ import {
|
|||||||
composePlatformPermissionRegistry,
|
composePlatformPermissionRegistry,
|
||||||
createModuleUiPublicationRegistry,
|
createModuleUiPublicationRegistry,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
startDiscoveredModuleRuntime,
|
startDiscoveredModuleRuntime,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import {
|
import {
|
||||||
@@ -241,7 +241,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
|||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.1.0",
|
||||||
});
|
});
|
||||||
const services: ModuleRuntimeServices = Object.freeze({
|
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
});
|
});
|
||||||
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
|
// The module's real runtime: its server entrypoint is imported, registered, and started, and the
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +19,8 @@ import {
|
|||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -192,7 +192,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
|||||||
revokeGrant: vi.fn(),
|
revokeGrant: vi.fn(),
|
||||||
revokePlatformOwner: vi.fn(),
|
revokePlatformOwner: vi.fn(),
|
||||||
} as unknown as AuthorizationStore;
|
} as unknown as AuthorizationStore;
|
||||||
const services: ModuleRuntimeServices = Object.freeze({
|
const services: LegacyModuleRuntimeServices = Object.freeze({
|
||||||
database: Object.freeze({}) as unknown as ModuleDatabase,
|
database: Object.freeze({}) as unknown as ModuleDatabase,
|
||||||
});
|
});
|
||||||
const permissionRegistry = composePlatformPermissionRegistry(modules);
|
const permissionRegistry = composePlatformPermissionRegistry(modules);
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import {
|
import { ANONYMOUS_PRINCIPAL } from "@ekklesia/contracts";
|
||||||
ANONYMOUS_PRINCIPAL,
|
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleRuntimeServices,
|
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
|
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
|
||||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +10,8 @@ import {
|
|||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||||
|
type LegacyModuleRuntimeServices,
|
||||||
|
type ModuleDatabase,
|
||||||
} from "@ekklesia/core/modules";
|
} from "@ekklesia/core/modules";
|
||||||
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
import { loadModuleUi } from "@ekklesia/module-ui-host";
|
||||||
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
|
||||||
@@ -215,7 +213,7 @@ async function install(
|
|||||||
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
|
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
|
||||||
|
|
||||||
const database = Object.freeze({}) as unknown as ModuleDatabase;
|
const database = Object.freeze({}) as unknown as ModuleDatabase;
|
||||||
const services: ModuleRuntimeServices = Object.freeze({ database });
|
const services: LegacyModuleRuntimeServices = Object.freeze({ database });
|
||||||
const runtime: EkklesiaIdentityRuntime = {
|
const runtime: EkklesiaIdentityRuntime = {
|
||||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||||
// Anonymous, and every authorization decision denied: whatever this route does, it does it
|
// Anonymous, and every authorization decision denied: whatever this route does, it does it
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/core",
|
"name": "@ekklesia/core",
|
||||||
"version": "0.1.2",
|
"version": "0.2.5",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class VerifyArgumentsError extends Error {}
|
|||||||
|
|
||||||
const USAGE =
|
const USAGE =
|
||||||
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
|
"Usage: verify-module-package <archive> [--sha256 <hex> | --sha256-file <file>] [--extract-to <new directory>]";
|
||||||
|
export const MODULE_VERIFIER_SCRATCH_ENV = "EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR";
|
||||||
|
|
||||||
export interface VerifyModulePackageArguments {
|
export interface VerifyModulePackageArguments {
|
||||||
readonly archivePath: string;
|
readonly archivePath: string;
|
||||||
@@ -90,6 +91,16 @@ export function parseSha256File(contents: string): string {
|
|||||||
return match[1].toLowerCase();
|
return match[1].toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The CLI's operator-facing bridge to Core's otherwise explicit verifier option. */
|
||||||
|
export function moduleVerifierScratchDirectory(environment: NodeJS.ProcessEnv): string | undefined {
|
||||||
|
const configured = environment[MODULE_VERIFIER_SCRATCH_ENV];
|
||||||
|
if (configured === undefined) return undefined;
|
||||||
|
if (configured === "" || !path.isAbsolute(configured)) {
|
||||||
|
throw new VerifyArgumentsError(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
|
||||||
|
}
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
|
||||||
async function readJson(file: string): Promise<Record<string, unknown>> {
|
async function readJson(file: string): Promise<Record<string, unknown>> {
|
||||||
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
|
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -121,6 +132,7 @@ export async function readModulePackagePlatform(
|
|||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
|
const parsed = parseVerifyModulePackageArguments(process.argv.slice(2));
|
||||||
|
const executionScratchDirectory = moduleVerifierScratchDirectory(process.env);
|
||||||
let expectedSha256 = parsed.expectedSha256;
|
let expectedSha256 = parsed.expectedSha256;
|
||||||
if (parsed.sha256File !== undefined) {
|
if (parsed.sha256File !== undefined) {
|
||||||
let contents: string;
|
let contents: string;
|
||||||
@@ -137,6 +149,7 @@ async function main(): Promise<void> {
|
|||||||
const result = await verifyModulePackage({
|
const result = await verifyModulePackage({
|
||||||
archivePath: parsed.archivePath,
|
archivePath: parsed.archivePath,
|
||||||
platform,
|
platform,
|
||||||
|
...(executionScratchDirectory === undefined ? {} : { executionScratchDirectory }),
|
||||||
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
|
...(expectedSha256 === undefined ? {} : { expectedSha256 }),
|
||||||
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
|
...(parsed.extractTo === undefined ? {} : { extractTo: parsed.extractTo }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,17 +44,26 @@ export class DeviceStoreError extends Error {
|
|||||||
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
export const DEVICE_PAIRING_REQUEST_TTL_MS = 10 * 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long a device credential is accepted before the device must pair again.
|
* How long a device credential may go **unused** before the device must pair again (ADR 0056).
|
||||||
*
|
*
|
||||||
* A display runs unattended for months; forcing a signage player to be re-paired every fortnight
|
* The window slides: every request from the device moves `lastSeenAt` forward, and the host
|
||||||
* because human sessions expire that fast would mean someone climbing to a ceiling-mounted screen
|
* re-issues the cookie with a fresh lifetime, so a screen that is in use stays paired until an
|
||||||
* on a schedule. What makes a long horizon safe is that it is not the security boundary: the server
|
* administrator revokes it. Revocation, not expiry, is the security boundary — the server checks it
|
||||||
* decides on every single request whether the device is still revoked, so an administrator's revoke
|
* on every single request. The window exists only so an abandoned, forgotten screen eventually stops
|
||||||
* takes effect immediately regardless of how long the credential would otherwise have lived. The
|
* being an identity at all: 180 days after it was last seen, its credential is dead.
|
||||||
* expiry exists so an abandoned, forgotten screen eventually stops being an identity at all.
|
|
||||||
*/
|
*/
|
||||||
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
export const DEVICE_CREDENTIAL_TTL_MS = 180 * 24 * 60 * 60_000;
|
||||||
|
|
||||||
|
/** When a device credential expires: the idle window, measured from the last time the device was
|
||||||
|
* seen, or from pairing if it never has been. */
|
||||||
|
export function deviceCredentialExpiresAt(input: {
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly lastSeenAt?: Date | null | undefined;
|
||||||
|
}): Date {
|
||||||
|
const anchor = Math.max(input.createdAt.getTime(), input.lastSeenAt?.getTime() ?? 0);
|
||||||
|
return new Date(anchor + DEVICE_CREDENTIAL_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
/** How stale `lastSeenAt` may become before the resolver spends a write on it. A display polling
|
||||||
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
* realtime and assets would otherwise turn an operational nicety into a continuous write load. */
|
||||||
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
export const DEVICE_LAST_SEEN_THROTTLE_MS = 15 * 60_000;
|
||||||
@@ -475,7 +484,8 @@ export function createDeviceStore(db: DeviceDrizzleDatabase): DeviceStore {
|
|||||||
}
|
}
|
||||||
// The credential's own horizon, measured from when the device was paired. Revocation is
|
// The credential's own horizon, measured from when the device was paired. Revocation is
|
||||||
// checked above and is what makes a long horizon acceptable.
|
// checked above and is what makes a long horizon acceptable.
|
||||||
if (row.createdAt.getTime() + DEVICE_CREDENTIAL_TTL_MS <= now.getTime()) {
|
// Expiry is measured from last use, not from pairing, so a screen in use never ages out.
|
||||||
|
if (deviceCredentialExpiresAt(row).getTime() <= now.getTime()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -1,7 +1,54 @@
|
|||||||
import type { ModuleDatabase } from "@ekklesia/contracts";
|
import type { ModuleDatabaseAbiRuntimeServices, ModuleRuntimeServices } from "@ekklesia/contracts";
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import type { Pool } from "pg";
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
|
||||||
|
* platform-owned Drizzle instance exposes, restricted to exactly the operations a module needs to
|
||||||
|
* read and write its own tables. Derived with `Pick` from the real `drizzle-orm/node-postgres`
|
||||||
|
* database type rather than hand-declared, so every method keeps Drizzle's own table-aware
|
||||||
|
* argument and result inference exactly as-is — a module calling `database.select().from(table)`
|
||||||
|
* or `database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
|
||||||
|
* would, with no `any`/`unknown` erasure anywhere in the chain.
|
||||||
|
*
|
||||||
|
* Deliberately excludes everything `NodePgDatabase` or the `drizzle(...)` factory otherwise
|
||||||
|
* exposes: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
|
||||||
|
* `query` (a relational-query builder tied to one schema, which would have to span every
|
||||||
|
* installed module's tables to exist here at all), `$cache`, `$count`, `$with`, `with`, and
|
||||||
|
* `refreshMaterializedView`. There is no connection string, pool, session, or dialect object
|
||||||
|
* reachable through this type. Carries no schema generic — a module brings its own Drizzle table
|
||||||
|
* objects to each call, so there is no global relational schema spanning every installed module for
|
||||||
|
* this type to leak.
|
||||||
|
*
|
||||||
|
* Core's own, private type (ADR 0053): it moved here from `@ekklesia/contracts` so no published
|
||||||
|
* package references Drizzle. New modules use the Module Database ABI v1 instead (ADR 0050), and
|
||||||
|
* the Module SDK never exposes this facade's type.
|
||||||
|
*
|
||||||
|
* This is an integration boundary, not a security sandbox: modules are trusted, compiled backend
|
||||||
|
* plugins that already run arbitrary code in this process, exactly like every other lifecycle hook
|
||||||
|
* and HTTP handler they define. `ModuleDatabase` exists to keep a module's code Drizzle-typed and
|
||||||
|
* decoupled from how the platform physically connects to PostgreSQL — not to defend against a
|
||||||
|
* malicious module, which this facade cannot do and does not attempt to do.
|
||||||
|
*/
|
||||||
|
export type ModuleDatabase = Pick<
|
||||||
|
NodePgDatabase,
|
||||||
|
| "select"
|
||||||
|
| "selectDistinct"
|
||||||
|
| "selectDistinctOn"
|
||||||
|
| "insert"
|
||||||
|
| "update"
|
||||||
|
| "delete"
|
||||||
|
| "execute"
|
||||||
|
| "transaction"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** The services a legacy module's hooks and handlers receive: the legacy facade. */
|
||||||
|
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
|
||||||
|
/** The services Core hands any started module: the legacy facade or the ABI v1 executor. */
|
||||||
|
export type AnyModuleRuntimeServices =
|
||||||
|
| LegacyModuleRuntimeServices
|
||||||
|
| ModuleDatabaseAbiRuntimeServices;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
|
* A typed `ModuleDatabase` view over an externally owned pool — the module-facing counterpart to
|
||||||
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
|
* `createIdentityDrizzleView` (`core/src/identity/database.ts`) and
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export * from "./lifecycle-schema.js";
|
|||||||
export * from "./lifecycle-store.js";
|
export * from "./lifecycle-store.js";
|
||||||
export * from "./migration-discovery.js";
|
export * from "./migration-discovery.js";
|
||||||
export * from "./modules-directory.js";
|
export * from "./modules-directory.js";
|
||||||
|
export * from "./module-package-host.js";
|
||||||
export * from "./module-package-verification.js";
|
export * from "./module-package-verification.js";
|
||||||
export * from "./permissions.js";
|
export * from "./permissions.js";
|
||||||
export * from "./realtime.js";
|
export * from "./realtime.js";
|
||||||
|
|||||||
+10
-10
@@ -1,15 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* The legacy module schema definition (ADR 0007): a v8 module that declares no `database` default-
|
||||||
|
* exports `{ moduleId, tables }` with its Drizzle table objects themselves. Core alone validates it,
|
||||||
|
* for the legacy facade's deprecation window (ADR 0050). It lives here, beside Core's own Drizzle
|
||||||
|
* dependency, so that no package a module installs needs Drizzle at run time (ADR 0053).
|
||||||
|
*
|
||||||
|
* Erasable TypeScript with no relative imports: the verifier's probe imports this source file
|
||||||
|
* directly in tests (`module-package-probe.ts`).
|
||||||
|
*/
|
||||||
import { is } from "drizzle-orm";
|
import { is } from "drizzle-orm";
|
||||||
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
|
import { type AnyPgTable, PgTable } from "drizzle-orm/pg-core";
|
||||||
import { type ModuleId, moduleIdSchema } from "./identifiers.js";
|
import { type ModuleId, moduleIdSchema } from "@ekklesia/contracts";
|
||||||
|
|
||||||
export {
|
|
||||||
moduleTableLocalNameSchema,
|
|
||||||
moduleTableName,
|
|
||||||
moduleTableNamespace,
|
|
||||||
moduleTablePrefix,
|
|
||||||
POSTGRES_IDENTIFIER_MAX_LENGTH,
|
|
||||||
physicalModuleTableNameSchema,
|
|
||||||
} from "./module-schema-names.js";
|
|
||||||
|
|
||||||
export interface ModuleSchemaDefinition<
|
export interface ModuleSchemaDefinition<
|
||||||
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
|
Tables extends Readonly<Record<string, AnyPgTable>> = Readonly<Record<string, AnyPgTable>>,
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* The facts about this host that decide whether a native Module Package v1 can run on it
|
||||||
|
* (ADR 0049): operating system, CPU, C library family and version, and Node-API version.
|
||||||
|
*
|
||||||
|
* Everything comes from Node.js itself (`process.platform`, `process.arch`, `process.versions`, and
|
||||||
|
* the diagnostic report's `glibcVersionRuntime`), never from running a system command. A fact Node
|
||||||
|
* cannot report is left undefined, and every check that needs it fails closed.
|
||||||
|
*/
|
||||||
|
export interface ModulePackageHost {
|
||||||
|
readonly platform: string;
|
||||||
|
readonly arch: string;
|
||||||
|
/** The C library on Linux: glibc with its runtime version, or undefined when it cannot be proven
|
||||||
|
* to be glibc (musl reports none). */
|
||||||
|
readonly libc?: { readonly family: "glibc"; readonly version: string };
|
||||||
|
/** `process.versions.napi`: the highest Node-API version this Node.js provides. */
|
||||||
|
readonly nodeApi?: number;
|
||||||
|
/** `process.versions.modules`: the ABI an ABI-specific addon must have been built for. */
|
||||||
|
readonly nodeAbi?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function glibcVersion(): string | undefined {
|
||||||
|
try {
|
||||||
|
const header = (process.report?.getReport() as { header?: { glibcVersionRuntime?: unknown } })
|
||||||
|
?.header;
|
||||||
|
const version = header?.glibcVersionRuntime;
|
||||||
|
return typeof version === "string" && /^\d+\.\d+(?:\.\d+)?$/u.test(version)
|
||||||
|
? version
|
||||||
|
: undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentModulePackageHost(): ModulePackageHost {
|
||||||
|
const libc = process.platform === "linux" ? glibcVersion() : undefined;
|
||||||
|
const nodeApi = Number(process.versions.napi);
|
||||||
|
return Object.freeze({
|
||||||
|
arch: process.arch,
|
||||||
|
...(libc === undefined
|
||||||
|
? {}
|
||||||
|
: { libc: Object.freeze({ family: "glibc" as const, version: libc }) }),
|
||||||
|
...(Number.isInteger(nodeApi) && nodeApi > 0 ? { nodeApi } : {}),
|
||||||
|
...(typeof process.versions.modules === "string" ? { nodeAbi: process.versions.modules } : {}),
|
||||||
|
platform: process.platform,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The native targets this platform can verify, with what each requires of a host. */
|
||||||
|
export const MODULE_PACKAGE_NATIVE_TARGETS: ReadonlyMap<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
readonly platform: string;
|
||||||
|
readonly arch: string;
|
||||||
|
readonly libc: "glibc";
|
||||||
|
readonly elfMachine: number;
|
||||||
|
}
|
||||||
|
> = new Map([
|
||||||
|
["linux-x64-glibc", { arch: "x64", elfMachine: 62, libc: "glibc", platform: "linux" }],
|
||||||
|
]);
|
||||||
@@ -9,14 +9,15 @@
|
|||||||
// The result travels over the IPC channel, never stdout, so nothing a module prints can forge it.
|
// The result travels over the IPC channel, never stdout, so nothing a module prints can forge it.
|
||||||
// It carries fixed role and reason names only: never an error message, a path, or module output.
|
// It carries fixed role and reason names only: never an error message, a path, or module output.
|
||||||
//
|
//
|
||||||
// Written as erasable TypeScript with no relative imports, so Node can run the source file directly
|
// Written as erasable TypeScript, so Node can run the source file directly in tests as well as the
|
||||||
// in tests as well as the compiled file in the image.
|
// compiled file in the image. Its one relative import names the source file's own extension, which
|
||||||
|
// the build rewrites to `.js` (`rewriteRelativeImportExtensions`); that file follows the same rules.
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
parseModuleRuntimeDefinition,
|
parseModuleRuntimeDefinition,
|
||||||
parseModuleSchemaDefinition,
|
|
||||||
parseModuleSchemaMetadataDefinition,
|
parseModuleSchemaMetadataDefinition,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import { parseModuleSchemaDefinition } from "./legacy-schema-definition.ts";
|
||||||
|
|
||||||
interface ProbeRequest {
|
interface ProbeRequest {
|
||||||
readonly databaseAbi?: 1;
|
readonly databaseAbi?: 1;
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import { fork } from "node:child_process";
|
import { fork } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import {
|
import {
|
||||||
isVersionCompatible,
|
isVersionCompatible,
|
||||||
|
type ModuleManifest,
|
||||||
|
safeParseModuleManifest,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
MODULE_PACKAGE_ANY_TARGET,
|
MODULE_PACKAGE_ANY_TARGET,
|
||||||
MODULE_PACKAGE_FORMAT,
|
MODULE_PACKAGE_FORMAT,
|
||||||
MODULE_PACKAGE_METADATA_FILENAME,
|
MODULE_PACKAGE_METADATA_FILENAME,
|
||||||
type ModuleManifest,
|
|
||||||
type ModulePackageMetadata,
|
type ModulePackageMetadata,
|
||||||
safeParseModuleManifest,
|
|
||||||
safeParseModulePackageMetadata,
|
safeParseModulePackageMetadata,
|
||||||
safeParseModulePackageRuntimePackage,
|
safeParseModulePackageRuntimePackage,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts/module-package";
|
||||||
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
|
import { discoverModules, MODULE_MANIFEST_FILENAME, ModuleDiscoveryError } from "./discovery.js";
|
||||||
|
import {
|
||||||
|
currentModulePackageHost,
|
||||||
|
MODULE_PACKAGE_NATIVE_TARGETS,
|
||||||
|
type ModulePackageHost,
|
||||||
|
} from "./module-package-host.js";
|
||||||
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
|
import { discoverModuleMigrations, ModuleMigrationDiscoveryError } from "./migration-discovery.js";
|
||||||
import {
|
import {
|
||||||
type ModulePackageArchive,
|
type ModulePackageArchive,
|
||||||
@@ -54,16 +61,20 @@ export type ModulePackageVerificationErrorCode =
|
|||||||
| "MODULE_PACKAGE_FILE_UNLISTED"
|
| "MODULE_PACKAGE_FILE_UNLISTED"
|
||||||
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
|
| "MODULE_PACKAGE_FORMAT_UNSUPPORTED"
|
||||||
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
|
| "MODULE_PACKAGE_IDENTITY_MISMATCH"
|
||||||
|
| "MODULE_PACKAGE_LIBC_INCOMPATIBLE"
|
||||||
| "MODULE_PACKAGE_MANIFEST_INVALID"
|
| "MODULE_PACKAGE_MANIFEST_INVALID"
|
||||||
| "MODULE_PACKAGE_METADATA_INVALID"
|
| "MODULE_PACKAGE_METADATA_INVALID"
|
||||||
| "MODULE_PACKAGE_METADATA_MISSING"
|
| "MODULE_PACKAGE_METADATA_MISSING"
|
||||||
| "MODULE_PACKAGE_MODULE_INVALID"
|
| "MODULE_PACKAGE_MODULE_INVALID"
|
||||||
| "MODULE_PACKAGE_NATIVE_FILE"
|
| "MODULE_PACKAGE_NATIVE_FILE"
|
||||||
|
| "MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE"
|
||||||
|
| "MODULE_PACKAGE_NODE_API_INCOMPATIBLE"
|
||||||
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
|
| "MODULE_PACKAGE_RUNTIME_INCOMPATIBLE"
|
||||||
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
|
| "MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID"
|
||||||
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
|
| "MODULE_PACKAGE_SDK_INCOMPATIBLE"
|
||||||
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
|
| "MODULE_PACKAGE_TARGET_INCOMPATIBLE"
|
||||||
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED";
|
| "MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED"
|
||||||
|
| "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE";
|
||||||
|
|
||||||
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
|
const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = Object.freeze({
|
||||||
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
|
MODULE_PACKAGE_DATABASE_RUNTIME_INCOMPATIBLE:
|
||||||
@@ -83,12 +94,19 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
|
|||||||
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
|
"The package metadata, runtime package.json, and manifest disagree about the module's identity.",
|
||||||
MODULE_PACKAGE_LAYOUT_INVALID:
|
MODULE_PACKAGE_LAYOUT_INVALID:
|
||||||
"The archive must contain exactly one top-level module directory, with each path once.",
|
"The archive must contain exactly one top-level module directory, with each path once.",
|
||||||
|
MODULE_PACKAGE_LIBC_INCOMPATIBLE:
|
||||||
|
"The package needs a newer C library than this host provides, or one it cannot establish.",
|
||||||
MODULE_PACKAGE_LINK: "The archive contains a link; module packages may contain none.",
|
MODULE_PACKAGE_LINK: "The archive contains a link; module packages may contain none.",
|
||||||
MODULE_PACKAGE_MANIFEST_INVALID: "The package's module manifest is missing or invalid.",
|
MODULE_PACKAGE_MANIFEST_INVALID: "The package's module manifest is missing or invalid.",
|
||||||
MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.",
|
MODULE_PACKAGE_METADATA_INVALID: "The package metadata is invalid.",
|
||||||
MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.",
|
MODULE_PACKAGE_METADATA_MISSING: "The archive contains no package metadata.",
|
||||||
MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.",
|
MODULE_PACKAGE_MODULE_INVALID: "The packaged module failed module validation.",
|
||||||
MODULE_PACKAGE_NATIVE_FILE: "An any-target package contains a native file.",
|
MODULE_PACKAGE_NATIVE_FILE:
|
||||||
|
"An any-target package contains a native file or a node_modules directory.",
|
||||||
|
MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE:
|
||||||
|
"A native file in the package is not built for the package's target.",
|
||||||
|
MODULE_PACKAGE_NODE_API_INCOMPATIBLE:
|
||||||
|
"The package's addons need a Node-API version or Node.js ABI this host does not provide.",
|
||||||
MODULE_PACKAGE_RUNTIME_INCOMPATIBLE:
|
MODULE_PACKAGE_RUNTIME_INCOMPATIBLE:
|
||||||
"The package needs a Node.js version this host does not run.",
|
"The package needs a Node.js version this host does not run.",
|
||||||
MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID:
|
MODULE_PACKAGE_RUNTIME_PACKAGE_INVALID:
|
||||||
@@ -101,6 +119,8 @@ const messages: Readonly<Record<ModulePackageVerificationErrorCode, string>> = O
|
|||||||
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
|
MODULE_PACKAGE_TOO_LARGE: "The archive exceeds the module package size limits.",
|
||||||
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
|
MODULE_PACKAGE_TRANSITIONAL_UNSUPPORTED:
|
||||||
"The package metadata contains transitional facts this platform does not understand.",
|
"The package metadata contains transitional facts this platform does not understand.",
|
||||||
|
MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE:
|
||||||
|
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
|
||||||
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
|
MODULE_PACKAGE_UNREADABLE: "The file is not a readable gzip-compressed ustar archive.",
|
||||||
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
|
MODULE_PACKAGE_UNSAFE_MODE: "The archive contains a setuid, setgid, or sticky entry.",
|
||||||
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
|
MODULE_PACKAGE_UNSAFE_PATH: "The archive contains an absolute, escaping, or non-portable path.",
|
||||||
@@ -129,6 +149,8 @@ export interface ModulePackagePlatform {
|
|||||||
readonly databaseRuntime: Readonly<Record<string, string>>;
|
readonly databaseRuntime: Readonly<Record<string, string>>;
|
||||||
/** Defaults to the running process's Node.js version. */
|
/** Defaults to the running process's Node.js version. */
|
||||||
readonly nodeVersion?: string;
|
readonly nodeVersion?: string;
|
||||||
|
/** Defaults to this process's own host (`currentModulePackageHost`). */
|
||||||
|
readonly host?: ModulePackageHost;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VerifyModulePackageOptions {
|
export interface VerifyModulePackageOptions {
|
||||||
@@ -138,6 +160,12 @@ export interface VerifyModulePackageOptions {
|
|||||||
readonly platform: ModulePackagePlatform;
|
readonly platform: ModulePackagePlatform;
|
||||||
/** A directory that must not exist yet; the verified module directory is written there. */
|
/** A directory that must not exist yet; the verified module directory is written there. */
|
||||||
readonly extractTo?: string;
|
readonly extractTo?: string;
|
||||||
|
/**
|
||||||
|
* Existing writable directory where backend candidates may execute. Defaults to `os.tmpdir()`.
|
||||||
|
* A hardened caller whose system temporary directory is `noexec` must provide a dedicated
|
||||||
|
* executable scratch mount; each verification creates and removes its own child directory.
|
||||||
|
*/
|
||||||
|
readonly executionScratchDirectory?: string;
|
||||||
readonly loadTimeoutMs?: number;
|
readonly loadTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,12 +292,19 @@ function checkCompatibility(
|
|||||||
files: ReadonlyMap<string, Buffer>,
|
files: ReadonlyMap<string, Buffer>,
|
||||||
platform: ModulePackagePlatform,
|
platform: ModulePackagePlatform,
|
||||||
): void {
|
): void {
|
||||||
// This verifier implements the portable target only. Native targets are a later slice.
|
if (metadata.target === MODULE_PACKAGE_ANY_TARGET) {
|
||||||
if (metadata.target !== MODULE_PACKAGE_ANY_TARGET) {
|
// An any-target package bundles everything: no addon, no ELF object, no node_modules.
|
||||||
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
|
for (const [filePath, data] of files) {
|
||||||
}
|
if (
|
||||||
if ([...files.keys()].some((filePath) => filePath.toLowerCase().endsWith(".node"))) {
|
filePath.toLowerCase().endsWith(".node") ||
|
||||||
fail("MODULE_PACKAGE_NATIVE_FILE");
|
filePath.startsWith("node_modules/") ||
|
||||||
|
isElf(data)
|
||||||
|
) {
|
||||||
|
fail("MODULE_PACKAGE_NATIVE_FILE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checkNativeTarget(metadata, files, platform.host ?? currentModulePackageHost());
|
||||||
}
|
}
|
||||||
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
|
if (!isVersionCompatible(platform.nodeVersion ?? process.versions.node, metadata.runtime.node)) {
|
||||||
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
|
fail("MODULE_PACKAGE_RUNTIME_INCOMPATIBLE");
|
||||||
@@ -294,6 +329,74 @@ function checkCompatibility(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isElf(data: Uint8Array): boolean {
|
||||||
|
return (
|
||||||
|
data.length >= 4 && data[0] === 0x7f && data[1] === 0x45 && data[2] === 0x4c && data[3] === 0x46
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `2.41` → `2.41.0`, for comparison against a semantic-version range. */
|
||||||
|
function asSemver(version: string): string {
|
||||||
|
const parts = version.split(".");
|
||||||
|
return [...parts, "0", "0"].slice(0, 3).join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A native target (ADR 0049): accepted only for a target this platform implements, on a host that
|
||||||
|
* provably matches it. Platform, CPU, C library family and minimum version, and the Node-API version
|
||||||
|
* or ABI the addons need are all checked; a fact the host cannot establish fails closed. Every ELF
|
||||||
|
* object in the package must be built for the target's machine, and every `.node` addon must be one.
|
||||||
|
*/
|
||||||
|
function checkNativeTarget(
|
||||||
|
metadata: ModulePackageMetadata,
|
||||||
|
files: ReadonlyMap<string, Buffer>,
|
||||||
|
host: ModulePackageHost,
|
||||||
|
): void {
|
||||||
|
const target = MODULE_PACKAGE_NATIVE_TARGETS.get(metadata.target);
|
||||||
|
if (
|
||||||
|
target === undefined ||
|
||||||
|
host.platform !== target.platform ||
|
||||||
|
host.arch !== target.arch ||
|
||||||
|
host.libc?.family !== target.libc
|
||||||
|
) {
|
||||||
|
fail("MODULE_PACKAGE_TARGET_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
const required = metadata.runtime.libcVersion;
|
||||||
|
if (
|
||||||
|
required === undefined ||
|
||||||
|
host.libc === undefined ||
|
||||||
|
!isVersionCompatible(asSemver(host.libc.version), required)
|
||||||
|
) {
|
||||||
|
fail("MODULE_PACKAGE_LIBC_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
const { nodeAbi, nodeApi } = metadata.runtime;
|
||||||
|
if (
|
||||||
|
(nodeApi === undefined && nodeAbi === undefined) ||
|
||||||
|
(nodeApi !== undefined && (host.nodeApi === undefined || host.nodeApi < nodeApi)) ||
|
||||||
|
(nodeAbi !== undefined && host.nodeAbi !== nodeAbi)
|
||||||
|
) {
|
||||||
|
fail("MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
for (const [filePath, data] of files) {
|
||||||
|
const addon = filePath.toLowerCase().endsWith(".node");
|
||||||
|
if (!isElf(data)) {
|
||||||
|
if (addon) {
|
||||||
|
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// ELF64, little-endian, for the target's machine (e_machine at offset 18).
|
||||||
|
if (
|
||||||
|
data.length < 20 ||
|
||||||
|
data[4] !== 2 ||
|
||||||
|
data[5] !== 1 ||
|
||||||
|
data.readUInt16LE(18) !== target.elfMachine
|
||||||
|
) {
|
||||||
|
fail("MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Writes the archive's directories (`0755`) and files (`0644`) under `root`, which must not exist.
|
/** Writes the archive's directories (`0755`) and files (`0644`) under `root`, which must not exist.
|
||||||
* Safe by construction: every path was validated as relative and non-escaping, and only directories
|
* Safe by construction: every path was validated as relative and non-escaping, and only directories
|
||||||
* and regular files exist. Ownership is the writing process's, never the archive's. */
|
* and regular files exist. Ownership is the writing process's, never the archive's. */
|
||||||
@@ -320,12 +423,66 @@ function probeEntrypointPath(): string {
|
|||||||
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
|
return path.join(path.dirname(self), `module-package-probe${path.extname(self)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodedMountPath(value: string): string {
|
||||||
|
return value.replace(/\\([0-7]{3})/gu, (_match, octal: string) =>
|
||||||
|
String.fromCharCode(Number.parseInt(octal, 8)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linux exposes the effective options of every mount, including a container's tmpfs mounts. A
|
||||||
|
* verifier cannot infer `noexec` from file permissions: native addons are mapped executable rather
|
||||||
|
* than invoked as files. On systems without mountinfo, the load remains the final authority. */
|
||||||
|
async function isNoExecMount(directory: string): Promise<boolean> {
|
||||||
|
if (process.platform !== "linux") return false;
|
||||||
|
let mountinfo: string;
|
||||||
|
let canonicalDirectory: string;
|
||||||
|
try {
|
||||||
|
[mountinfo, canonicalDirectory] = await Promise.all([
|
||||||
|
readFile("/proc/self/mountinfo", "utf8"),
|
||||||
|
realpath(directory),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const mounts = mountinfo
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.split(" "))
|
||||||
|
.filter((fields) => fields.length > 6 && fields.includes("-"))
|
||||||
|
.map((fields) => ({
|
||||||
|
mountPoint: decodedMountPath(fields[4] ?? ""),
|
||||||
|
options: (fields[5] ?? "").split(","),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
({ mountPoint }) =>
|
||||||
|
canonicalDirectory === mountPoint ||
|
||||||
|
canonicalDirectory.startsWith(mountPoint === "/" ? "/" : `${mountPoint}/`),
|
||||||
|
)
|
||||||
|
.sort((left, right) => right.mountPoint.length - left.mountPoint.length);
|
||||||
|
return mounts[0]?.options.includes("noexec") ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createExecutionScratch(baseDirectory: string): Promise<string> {
|
||||||
|
if (!path.isAbsolute(baseDirectory)) {
|
||||||
|
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (await isNoExecMount(baseDirectory)) {
|
||||||
|
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
return await mkdtemp(path.join(baseDirectory, "ekklesia-package-verify-"));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ModulePackageVerificationError) throw error;
|
||||||
|
fail("MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
|
/** Loads every backend entrypoint in a short-lived child with an empty environment, and reports
|
||||||
* only whether each loaded and exported a definition naming this module. */
|
* only whether each loaded and exported a definition naming this module. */
|
||||||
async function loadEntrypoints(
|
async function loadEntrypoints(
|
||||||
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
|
manifest: Pick<ModuleManifest, "database" | "id" | "version">,
|
||||||
entrypoints: Readonly<Record<string, string>>,
|
entrypoints: Readonly<Record<string, string>>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
|
executionDirectory: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const outcome = await new Promise<"ok" | "failed">((resolve) => {
|
const outcome = await new Promise<"ok" | "failed">((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -351,7 +508,7 @@ async function loadEntrypoints(
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
cwd: tmpdir(),
|
cwd: executionDirectory,
|
||||||
env: {},
|
env: {},
|
||||||
execArgv: [],
|
execArgv: [],
|
||||||
serialization: "json",
|
serialization: "json",
|
||||||
@@ -420,7 +577,7 @@ export async function verifyModulePackage(
|
|||||||
const manifest = checkIdentity(archive, metadata, files);
|
const manifest = checkIdentity(archive, metadata, files);
|
||||||
checkCompatibility(metadata, manifest, files, options.platform);
|
checkCompatibility(metadata, manifest, files, options.platform);
|
||||||
|
|
||||||
const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-package-verify-"));
|
const scratch = await createExecutionScratch(options.executionScratchDirectory ?? tmpdir());
|
||||||
try {
|
try {
|
||||||
// The candidate alone, in a scratch inventory of its own: never module storage.
|
// The candidate alone, in a scratch inventory of its own: never module storage.
|
||||||
const modulesDirectory = path.join(scratch, "candidates");
|
const modulesDirectory = path.join(scratch, "candidates");
|
||||||
@@ -456,7 +613,7 @@ export async function verifyModulePackage(
|
|||||||
|
|
||||||
// Executed only now, and first in isolation: a schema is imported in this process below only
|
// Executed only now, and first in isolation: a schema is imported in this process below only
|
||||||
// after the probe proved it loads on its own.
|
// after the probe proved it loads on its own.
|
||||||
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000);
|
await loadEntrypoints(manifest, entrypoints, options.loadTimeoutMs ?? 30_000, scratch);
|
||||||
try {
|
try {
|
||||||
await discoverModuleSchemas(discoveryOptions);
|
await discoverModuleSchemas(discoveryOptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
type AnyModuleRuntimeServices,
|
|
||||||
type CapabilityId,
|
type CapabilityId,
|
||||||
type CapabilityRegistrar,
|
type CapabilityRegistrar,
|
||||||
type CapabilityResolver,
|
type CapabilityResolver,
|
||||||
@@ -8,7 +7,6 @@ import {
|
|||||||
EMPTY_MODULE_CONFIGURATION,
|
EMPTY_MODULE_CONFIGURATION,
|
||||||
EMPTY_MODULE_STORAGE,
|
EMPTY_MODULE_STORAGE,
|
||||||
type ModuleConfiguration,
|
type ModuleConfiguration,
|
||||||
type ModuleDatabase,
|
|
||||||
type ModuleDatabaseExecutor,
|
type ModuleDatabaseExecutor,
|
||||||
type ModuleHttpRouteRegistrar,
|
type ModuleHttpRouteRegistrar,
|
||||||
type ModuleId,
|
type ModuleId,
|
||||||
@@ -19,7 +17,6 @@ import {
|
|||||||
type ModuleRouteHandler,
|
type ModuleRouteHandler,
|
||||||
type ModuleRuntimeContext,
|
type ModuleRuntimeContext,
|
||||||
type ModuleRuntimeDefinition,
|
type ModuleRuntimeDefinition,
|
||||||
type ModuleRuntimeServices,
|
|
||||||
type ModuleStorage,
|
type ModuleStorage,
|
||||||
moduleRouteShapeKey,
|
moduleRouteShapeKey,
|
||||||
moduleRoutesOverlap,
|
moduleRoutesOverlap,
|
||||||
@@ -28,6 +25,7 @@ import {
|
|||||||
type RegisteredModuleHttpRoute,
|
type RegisteredModuleHttpRoute,
|
||||||
safeParseModuleRouteDefinition,
|
safeParseModuleRouteDefinition,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import type { AnyModuleRuntimeServices, ModuleDatabase } from "./database.js";
|
||||||
import {
|
import {
|
||||||
type DiscoveredModule,
|
type DiscoveredModule,
|
||||||
discoverModules,
|
discoverModules,
|
||||||
@@ -137,7 +135,7 @@ export type ModuleStorageLookup = ReadonlyMap<string, ModuleStorage>;
|
|||||||
/** One frozen service object per module id. Legacy modules may point at objects wrapping the same
|
/** One frozen service object per module id. Legacy modules may point at objects wrapping the same
|
||||||
* facade; ABI-v1 modules each point at their own attributed executor. */
|
* facade; ABI-v1 modules each point at their own attributed executor. */
|
||||||
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
|
export type ModuleRuntimeServicesLookup = ReadonlyMap<string, AnyModuleRuntimeServices>;
|
||||||
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | ModuleRuntimeServices;
|
export type ModuleRuntimeServicesSource = ModuleRuntimeServicesLookup | AnyModuleRuntimeServices;
|
||||||
|
|
||||||
function servicesFor(
|
function servicesFor(
|
||||||
services: ModuleRuntimeServicesSource,
|
services: ModuleRuntimeServicesSource,
|
||||||
@@ -347,11 +345,11 @@ class StagedCapabilityRegistry {
|
|||||||
createRuntimeContext(
|
createRuntimeContext(
|
||||||
module: DiscoveredModule,
|
module: DiscoveredModule,
|
||||||
phase: "rollback" | "start" | "stop",
|
phase: "rollback" | "start" | "stop",
|
||||||
services: ModuleRuntimeServices,
|
services: AnyModuleRuntimeServices,
|
||||||
config: ModuleConfiguration,
|
config: ModuleConfiguration,
|
||||||
storage: ModuleStorage,
|
storage: ModuleStorage,
|
||||||
realtime: ModuleRealtimePublisher,
|
realtime: ModuleRealtimePublisher,
|
||||||
): ModuleRuntimeContext {
|
): ModuleRuntimeContext<ModuleDatabase | ModuleDatabaseExecutor> {
|
||||||
const capabilities: CapabilityResolver = Object.freeze({
|
const capabilities: CapabilityResolver = Object.freeze({
|
||||||
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
resolve: (capabilityId: CapabilityId): RegisteredCapability | undefined => {
|
||||||
this.#assertAccess(module, capabilityId, phase);
|
this.#assertAccess(module, capabilityId, phase);
|
||||||
@@ -754,7 +752,7 @@ async function stopModules(
|
|||||||
registry.createRuntimeContext(
|
registry.createRuntimeContext(
|
||||||
module.discovered,
|
module.discovered,
|
||||||
phase,
|
phase,
|
||||||
servicesFor(services, module.discovered) as ModuleRuntimeServices,
|
servicesFor(services, module.discovered),
|
||||||
configurationFor(configurations, moduleId),
|
configurationFor(configurations, moduleId),
|
||||||
storageFor(storages, moduleId),
|
storageFor(storages, moduleId),
|
||||||
realtimeRegistry.publisherFor(moduleId),
|
realtimeRegistry.publisherFor(moduleId),
|
||||||
@@ -814,7 +812,7 @@ async function startModules(
|
|||||||
registry.createRuntimeContext(
|
registry.createRuntimeContext(
|
||||||
module.discovered,
|
module.discovered,
|
||||||
"start",
|
"start",
|
||||||
servicesFor(services, module.discovered) as ModuleRuntimeServices,
|
servicesFor(services, module.discovered),
|
||||||
configurationFor(configurations, module.discovered.manifest.id),
|
configurationFor(configurations, module.discovered.manifest.id),
|
||||||
storageFor(storages, module.discovered.manifest.id),
|
storageFor(storages, module.discovered.manifest.id),
|
||||||
realtimeRegistry.publisherFor(module.discovered.manifest.id),
|
realtimeRegistry.publisherFor(module.discovered.manifest.id),
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import {
|
import {
|
||||||
type ModuleId,
|
type ModuleId,
|
||||||
type ModuleSchemaDefinition,
|
|
||||||
type ModuleSchemaMetadataDefinition,
|
type ModuleSchemaMetadataDefinition,
|
||||||
moduleTableLocalNameSchema,
|
moduleTableLocalNameSchema,
|
||||||
moduleTablePrefix,
|
moduleTablePrefix,
|
||||||
parseModuleSchemaDefinition,
|
|
||||||
parseModuleSchemaMetadataDefinition,
|
parseModuleSchemaMetadataDefinition,
|
||||||
physicalModuleTableNameSchema,
|
physicalModuleTableNameSchema,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
import {
|
||||||
|
type ModuleSchemaDefinition,
|
||||||
|
parseModuleSchemaDefinition,
|
||||||
|
} from "./legacy-schema-definition.js";
|
||||||
import { is } from "drizzle-orm";
|
import { is } from "drizzle-orm";
|
||||||
import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core";
|
import { type AnyPgTable, getTableConfig, PgColumn, PgTable } from "drizzle-orm/pg-core";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||||
|
|
||||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
@@ -7,16 +8,20 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
|
|||||||
describe("bootstrap-platform-owner CLI import safety", () => {
|
describe("bootstrap-platform-owner CLI import safety", () => {
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
it("does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module", async () => {
|
it(
|
||||||
delete process.env.DATABASE_URL;
|
"does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module",
|
||||||
|
async () => {
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
|
|
||||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
|
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
|
||||||
// false the same way it naturally is for apps/api/src/server.ts under its own test suite —
|
// false the same way it naturally is for apps/api/src/server.ts under its own test suite —
|
||||||
// no argv manipulation is needed to prove the guard holds.
|
// no argv manipulation is needed to prove the guard holds.
|
||||||
await expect(import("../src/cli/bootstrap-platform-owner.js")).resolves.toBeDefined();
|
await expect(import("../src/cli/bootstrap-platform-owner.js")).resolves.toBeDefined();
|
||||||
|
|
||||||
expect(poolConstructor).not.toHaveBeenCalled();
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
|
||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
});
|
},
|
||||||
|
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||||
|
|
||||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
@@ -7,18 +8,22 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
|
|||||||
describe("create-initial-owner CLI import safety", () => {
|
describe("create-initial-owner CLI import safety", () => {
|
||||||
const originalEnv = { ...process.env };
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
it("does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module", async () => {
|
it(
|
||||||
delete process.env.DATABASE_URL;
|
"does not read DATABASE_URL, connect to PostgreSQL, or create anything merely by importing the module",
|
||||||
|
async () => {
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
|
|
||||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
|
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is false
|
||||||
// exactly as it is for `bootstrap-platform-owner.ts` — no argv manipulation is needed to prove
|
// exactly as it is for `bootstrap-platform-owner.ts` — no argv manipulation is needed to prove
|
||||||
// the guard holds. Importing the command must never prompt, connect, or write.
|
// the guard holds. Importing the command must never prompt, connect, or write.
|
||||||
await expect(import("../src/cli/create-initial-owner.js")).resolves.toBeDefined();
|
await expect(import("../src/cli/create-initial-owner.js")).resolves.toBeDefined();
|
||||||
|
|
||||||
expect(poolConstructor).not.toHaveBeenCalled();
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
|
||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
});
|
},
|
||||||
|
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
it("reads no environment variable for the password", async () => {
|
it("reads no environment variable for the password", async () => {
|
||||||
// The password is prompted for or read from standard input; it is deliberately never taken from
|
// The password is prompted for or read from standard input; it is deliberately never taken from
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||||
|
|
||||||
const discoverModules = vi.fn();
|
const discoverModules = vi.fn();
|
||||||
const discoverModuleSchemas = vi.fn();
|
const discoverModuleSchemas = vi.fn();
|
||||||
@@ -22,24 +23,28 @@ vi.mock("../src/migrations/index.js", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Doctor import safety", () => {
|
describe("Doctor import safety", () => {
|
||||||
it("does not discover, connect, plan, or print when imported", async () => {
|
it(
|
||||||
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
"does not discover, connect, plan, or print when imported",
|
||||||
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
async () => {
|
||||||
|
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
||||||
|
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
|
|
||||||
await import("../src/diagnostics/index.js");
|
await import("../src/diagnostics/index.js");
|
||||||
await import("../src/cli/doctor.js");
|
await import("../src/cli/doctor.js");
|
||||||
await import("../src/index.js");
|
await import("../src/index.js");
|
||||||
|
|
||||||
expect(discoverModules).not.toHaveBeenCalled();
|
expect(discoverModules).not.toHaveBeenCalled();
|
||||||
expect(discoverModuleSchemas).not.toHaveBeenCalled();
|
expect(discoverModuleSchemas).not.toHaveBeenCalled();
|
||||||
expect(discoverModuleMigrations).not.toHaveBeenCalled();
|
expect(discoverModuleMigrations).not.toHaveBeenCalled();
|
||||||
expect(startModuleRuntime).not.toHaveBeenCalled();
|
expect(startModuleRuntime).not.toHaveBeenCalled();
|
||||||
expect(createPostgresMigrationDatabase).not.toHaveBeenCalled();
|
expect(createPostgresMigrationDatabase).not.toHaveBeenCalled();
|
||||||
expect(planModuleMigrations).not.toHaveBeenCalled();
|
expect(planModuleMigrations).not.toHaveBeenCalled();
|
||||||
expect(log).not.toHaveBeenCalled();
|
expect(log).not.toHaveBeenCalled();
|
||||||
expect(error).not.toHaveBeenCalled();
|
expect(error).not.toHaveBeenCalled();
|
||||||
|
|
||||||
log.mockRestore();
|
log.mockRestore();
|
||||||
error.mockRestore();
|
error.mockRestore();
|
||||||
});
|
},
|
||||||
|
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.2.0",
|
||||||
"moduleSdk": "^0.1.0"
|
"moduleSdk": "^0.2.0"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { alias, getTableConfig, integer, pgTable } from "drizzle-orm/pg-core";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseModuleSchemaDefinition } from "../src/modules/legacy-schema-definition.js";
|
||||||
|
|
||||||
|
describe("the legacy module schema definition", () => {
|
||||||
|
it("normalizes and freezes a typed schema definition without freezing its tables", () => {
|
||||||
|
const records = pgTable("example_records", { id: integer().primaryKey() });
|
||||||
|
const definition = parseModuleSchemaDefinition({
|
||||||
|
moduleId: "example",
|
||||||
|
tables: { records },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(definition.tables.records).toBe(records);
|
||||||
|
expect(getTableConfig(records).name).toBe("example_records");
|
||||||
|
expect(Object.isFrozen(definition)).toBe(true);
|
||||||
|
expect(Object.isFrozen(definition.tables)).toBe(true);
|
||||||
|
expect(Object.isFrozen(records)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed, empty, unknown, and non-PostgreSQL table definitions", () => {
|
||||||
|
const records = pgTable("example_records", { id: integer() });
|
||||||
|
const disguisedRecords = alias(records, "example_disguised_records");
|
||||||
|
expect(() => parseModuleSchemaDefinition({ moduleId: "example", tables: {} })).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
parseModuleSchemaDefinition({ moduleId: "example", tables: { records: {} } }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
parseModuleSchemaDefinition({ moduleId: "example", tables: { records }, extra: true }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
parseModuleSchemaDefinition({ moduleId: "example", tables: { disguisedRecords } }),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
+9
-8
@@ -1,8 +1,8 @@
|
|||||||
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { ModuleDatabase, ModuleRuntimeServices } from "../src/index.js";
|
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
|
||||||
|
|
||||||
const testTable = pgTable("contracts_module_database_test", {
|
const testTable = pgTable("core_module_database_type_test", {
|
||||||
id: integer("id").primaryKey(),
|
id: integer("id").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
});
|
});
|
||||||
@@ -68,16 +68,17 @@ function typeOnlyExclusions(database: ModuleDatabase): void {
|
|||||||
}
|
}
|
||||||
void typeOnlyExclusions;
|
void typeOnlyExclusions;
|
||||||
|
|
||||||
/** Never invoked — proves `ModuleRuntimeServices` has exactly one field, `database`, typed as
|
/** Never invoked — proves a legacy module's `ModuleRuntimeServices` has exactly one field,
|
||||||
* `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization field this phase. */
|
* `database`, typed as `ModuleDatabase`, with no storage/jobs/configuration/secrets/authorization
|
||||||
function typeOnlyServicesShape(services: ModuleRuntimeServices): void {
|
* field this phase. */
|
||||||
|
function typeOnlyServicesShape(services: LegacyModuleRuntimeServices): void {
|
||||||
const database: ModuleDatabase = services.database;
|
const database: ModuleDatabase = services.database;
|
||||||
void database;
|
void database;
|
||||||
// @ts-expect-error `storage` does not exist on `ModuleRuntimeServices` yet.
|
// @ts-expect-error `storage` does not exist on the services yet.
|
||||||
void services.storage;
|
void services.storage;
|
||||||
// @ts-expect-error `jobs` does not exist on `ModuleRuntimeServices` yet.
|
// @ts-expect-error `jobs` does not exist on the services yet.
|
||||||
void services.jobs;
|
void services.jobs;
|
||||||
// @ts-expect-error `authorization` does not exist on `ModuleRuntimeServices` yet.
|
// @ts-expect-error `authorization` does not exist on the services yet.
|
||||||
void services.authorization;
|
void services.authorization;
|
||||||
}
|
}
|
||||||
void typeOnlyServicesShape;
|
void typeOnlyServicesShape;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { IMPORT_SAFETY_TEST_TIMEOUT_MS } from "./support/import-safety.js";
|
||||||
|
|
||||||
const readdir = vi.fn();
|
const readdir = vi.fn();
|
||||||
const readFile = vi.fn();
|
const readFile = vi.fn();
|
||||||
@@ -14,13 +15,17 @@ vi.mock("node:fs/promises", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe("module HTTP contract and runtime import safety", () => {
|
describe("module HTTP contract and runtime import safety", () => {
|
||||||
it("performs no filesystem I/O merely by importing the module runtime or the module-http contract", async () => {
|
it(
|
||||||
await import("../src/modules/index.js");
|
"performs no filesystem I/O merely by importing the module runtime or the module-http contract",
|
||||||
await import("@ekklesia/contracts");
|
async () => {
|
||||||
|
await import("../src/modules/index.js");
|
||||||
|
await import("@ekklesia/contracts");
|
||||||
|
|
||||||
expect(readdir).not.toHaveBeenCalled();
|
expect(readdir).not.toHaveBeenCalled();
|
||||||
expect(readFile).not.toHaveBeenCalled();
|
expect(readFile).not.toHaveBeenCalled();
|
||||||
expect(realpath).not.toHaveBeenCalled();
|
expect(realpath).not.toHaveBeenCalled();
|
||||||
expect(stat).not.toHaveBeenCalled();
|
expect(stat).not.toHaveBeenCalled();
|
||||||
});
|
},
|
||||||
|
IMPORT_SAFETY_TEST_TIMEOUT_MS,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ async function createModulesDirectory(): Promise<string> {
|
|||||||
|
|
||||||
function discoveryOptions(modulesDirectory: string) {
|
function discoveryOptions(modulesDirectory: string) {
|
||||||
return {
|
return {
|
||||||
moduleSdkVersion: "0.1.0",
|
moduleSdkVersion: "0.2.0",
|
||||||
modulesDirectory,
|
modulesDirectory,
|
||||||
platformVersion: "0.1.0",
|
platformVersion: "0.2.0",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
currentModulePackageHost,
|
||||||
|
type ModulePackageHost,
|
||||||
ModulePackageVerificationError,
|
ModulePackageVerificationError,
|
||||||
type ModulePackageVerificationErrorCode,
|
type ModulePackageVerificationErrorCode,
|
||||||
verifyModulePackage,
|
verifyModulePackage,
|
||||||
@@ -16,8 +18,8 @@ import {
|
|||||||
|
|
||||||
const platform = {
|
const platform = {
|
||||||
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
databaseRuntime: { "drizzle-orm": "0.45.2" },
|
||||||
moduleSdkVersion: "0.1.2",
|
moduleSdkVersion: "0.2.0",
|
||||||
platformVersion: "0.1.2",
|
platformVersion: "0.2.0",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const temporaryDirectories: string[] = [];
|
const temporaryDirectories: string[] = [];
|
||||||
@@ -46,6 +48,12 @@ async function archiveFile(options: PackageFixtureOptions = {}): Promise<{
|
|||||||
return { archivePath, sha256: fixture.sha256 };
|
return { archivePath, sha256: fixture.sha256 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function executionScratch(): Promise<string> {
|
||||||
|
const directory = path.join(await scratch(), "execution");
|
||||||
|
await mkdir(directory);
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
async function rejection(
|
async function rejection(
|
||||||
options: PackageFixtureOptions,
|
options: PackageFixtureOptions,
|
||||||
code: ModulePackageVerificationErrorCode,
|
code: ModulePackageVerificationErrorCode,
|
||||||
@@ -91,19 +99,66 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
|||||||
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
|
const result = await verifyModulePackage({ archivePath, expectedSha256: digest, platform });
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
archiveSha256: digest,
|
archiveSha256: digest,
|
||||||
fileCount: 8,
|
fileCount: 9,
|
||||||
moduleId: "example",
|
moduleId: "example",
|
||||||
target: "any",
|
target: "any",
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses an explicit execution scratch for pure JavaScript and cleans concurrent runs", async () => {
|
||||||
|
const { archivePath } = await archiveFile();
|
||||||
|
const executionScratchDirectory = await executionScratch();
|
||||||
|
const runs = Array.from({ length: 4 }, () =>
|
||||||
|
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||||
|
);
|
||||||
|
await expect(Promise.all(runs)).resolves.toHaveLength(4);
|
||||||
|
expect(await readdir(executionScratchDirectory)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cleans its execution scratch after a backend load failure", async () => {
|
||||||
|
const { archivePath } = await archiveFile({
|
||||||
|
serverSource:
|
||||||
|
'import "dependency-not-in-this-package";\nexport default { id: "example", version: "0.1.0" };\n',
|
||||||
|
});
|
||||||
|
const executionScratchDirectory = await executionScratch();
|
||||||
|
await expect(
|
||||||
|
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||||
|
).rejects.toMatchObject({ code: "MODULE_PACKAGE_ENTRYPOINT_LOAD_FAILED" });
|
||||||
|
expect(await readdir(executionScratchDirectory)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing execution scratch as a verifier environment error", async () => {
|
||||||
|
const { archivePath } = await archiveFile();
|
||||||
|
const executionScratchDirectory = path.join(await scratch(), "missing");
|
||||||
|
await expect(
|
||||||
|
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: "MODULE_PACKAGE_VERIFIER_SCRATCH_UNAVAILABLE",
|
||||||
|
message:
|
||||||
|
"The verifier execution scratch directory is missing, not writable, or mounted noexec.",
|
||||||
|
});
|
||||||
|
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unsafe archive before consulting the execution scratch", async () => {
|
||||||
|
const { archivePath } = await archiveFile(
|
||||||
|
withEntry({ path: "example/../attacker-chosen", data: "x" }),
|
||||||
|
);
|
||||||
|
const executionScratchDirectory = path.join(await scratch(), "missing");
|
||||||
|
await expect(
|
||||||
|
verifyModulePackage({ archivePath, executionScratchDirectory, platform }),
|
||||||
|
).rejects.toMatchObject({ code: "MODULE_PACKAGE_UNSAFE_PATH" });
|
||||||
|
await expect(stat(executionScratchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
});
|
||||||
|
|
||||||
it("extracts only a verified package, with normalized modes, into a new directory", async () => {
|
it("extracts only a verified package, with normalized modes, into a new directory", async () => {
|
||||||
const { archivePath } = await archiveFile();
|
const { archivePath } = await archiveFile();
|
||||||
const target = path.join(await scratch(), "example");
|
const target = path.join(await scratch(), "example");
|
||||||
await verifyModulePackage({ archivePath, extractTo: target, platform });
|
await verifyModulePackage({ archivePath, extractTo: target, platform });
|
||||||
const names = (await readdir(target, { recursive: true })).sort();
|
const names = (await readdir(target, { recursive: true })).sort();
|
||||||
expect(names).toContain("ekklesia.package.json");
|
expect(names).toContain("ekklesia.package.json");
|
||||||
|
expect(names).toContain("LICENSE");
|
||||||
expect(names).toContain("dist/server/index.js");
|
expect(names).toContain("dist/server/index.js");
|
||||||
expect((await stat(target)).mode & 0o7777).toBe(0o755);
|
expect((await stat(target)).mode & 0o7777).toBe(0o755);
|
||||||
expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644);
|
expect((await stat(path.join(target, "dist", "server", "index.js"))).mode & 0o7777).toBe(0o644);
|
||||||
@@ -153,6 +208,88 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a missing license declaration", undefined],
|
||||||
|
["an empty license declaration", ""],
|
||||||
|
["UNLICENSED", "UNLICENSED"],
|
||||||
|
["a malformed SPDX expression", "MIT OR"],
|
||||||
|
])("fails package metadata with %s", async (_label, license) => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => {
|
||||||
|
const changed: Record<string, unknown> = { ...metadata, license };
|
||||||
|
if (license === undefined) {
|
||||||
|
delete changed.license;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed on pre-ADR-0051 metadata", async () => {
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => {
|
||||||
|
const old: Record<string, unknown> = { ...metadata };
|
||||||
|
delete old.license;
|
||||||
|
return old;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires LICENSE at the canonical path in both payload and inventory", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ tamper: (files) => void files.delete("LICENSE") },
|
||||||
|
"MODULE_PACKAGE_FILE_MISSING",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
files: (metadata.files as { path: string }[]).filter((file) => file.path !== "LICENSE"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
withEntry({ path: "example/licenses/LICENSE", data: "attacker-chosen" }),
|
||||||
|
"MODULE_PACKAGE_FILE_UNLISTED",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
withEntry({ path: "example/COPYING", data: "attacker-chosen" }),
|
||||||
|
"MODULE_PACKAGE_FILE_UNLISTED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks the listed LICENSE bytes, size, and digest", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ tamper: (files) => files.set("LICENSE", Buffer.from("attacker-chosen")) },
|
||||||
|
"MODULE_PACKAGE_FILE_MISMATCH",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
{
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
files: (metadata.files as { path: string; size: number; sha256: string }[]).map((file) =>
|
||||||
|
file.path === "LICENSE" ? { ...file, size: file.size + 1 } : file,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
"MODULE_PACKAGE_FILE_MISMATCH",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate LICENSE archive entry", async () => {
|
||||||
|
await rejection(
|
||||||
|
withEntry({ path: "example/LICENSE", data: "attacker-chosen" }),
|
||||||
|
"MODULE_PACKAGE_LAYOUT_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("fails when the manifest and the package disagree about the version", async () => {
|
it("fails when the manifest and the package disagree about the version", async () => {
|
||||||
await rejection(
|
await rejection(
|
||||||
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
|
{ manifest: (manifest) => ({ ...manifest, version: "0.2.0" }) },
|
||||||
@@ -271,7 +408,7 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
|||||||
metadata: (metadata) => ({
|
metadata: (metadata) => ({
|
||||||
...metadata,
|
...metadata,
|
||||||
target: "linux-arm64-musl",
|
target: "linux-arm64-musl",
|
||||||
runtime: { node: ">=20", nodeApi: 9 },
|
runtime: { libcVersion: ">=1.2.0", node: ">=20", nodeApi: 9 },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||||
@@ -410,6 +547,179 @@ describe("Module Package v1 verification (ADR 0049)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("native targets (ADR 0049)", () => {
|
||||||
|
/** The first bytes of a 64-bit little-endian ELF object for `machine` (62 is x86-64). The
|
||||||
|
* verifier reads only the identification and machine fields. */
|
||||||
|
function elfHeader(machine = 62): Buffer {
|
||||||
|
const header = Buffer.alloc(64);
|
||||||
|
Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]).copy(header);
|
||||||
|
header.writeUInt16LE(3, 16);
|
||||||
|
header.writeUInt16LE(machine, 18);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linuxHost = {
|
||||||
|
arch: "x64",
|
||||||
|
libc: { family: "glibc", version: "2.41" },
|
||||||
|
nodeApi: 10,
|
||||||
|
nodeAbi: "137",
|
||||||
|
platform: "linux",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function nativePackage(
|
||||||
|
overrides: {
|
||||||
|
readonly files?: Record<string, Uint8Array | string>;
|
||||||
|
readonly target?: string;
|
||||||
|
readonly runtime?: Record<string, unknown>;
|
||||||
|
} = {},
|
||||||
|
): PackageFixtureOptions {
|
||||||
|
return {
|
||||||
|
files: overrides.files ?? {
|
||||||
|
"node_modules/native-thing/index.js": "module.exports = {};\n",
|
||||||
|
"node_modules/native-thing/lib/native-thing.node": elfHeader(),
|
||||||
|
"node_modules/native-thing/package.json": '{"name":"native-thing","version":"1.0.0"}\n',
|
||||||
|
},
|
||||||
|
metadata: (metadata) => ({
|
||||||
|
...metadata,
|
||||||
|
runtime: overrides.runtime ?? {
|
||||||
|
libcVersion: ">=2.28.0",
|
||||||
|
node: ">=20",
|
||||||
|
nodeApi: 9,
|
||||||
|
},
|
||||||
|
target: overrides.target ?? "linux-x64-glibc",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyOn(
|
||||||
|
options: PackageFixtureOptions,
|
||||||
|
host: ModulePackageHost,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const { archivePath } = await archiveFile(options);
|
||||||
|
return verifyModulePackage({ archivePath, platform: { ...platform, host } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refusedOn(
|
||||||
|
options: PackageFixtureOptions,
|
||||||
|
host: ModulePackageHost,
|
||||||
|
code: ModulePackageVerificationErrorCode,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(verifyOn(options, host)).rejects.toMatchObject({ code });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("accepts linux-x64-glibc on a matching host, native node_modules and addon included", async () => {
|
||||||
|
await expect(verifyOn(nativePackage(), linuxHost)).resolves.toMatchObject({
|
||||||
|
moduleId: "example",
|
||||||
|
target: "linux-x64-glibc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses another operating system, CPU, or C library family", async () => {
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage(),
|
||||||
|
{ ...linuxHost, platform: "darwin" },
|
||||||
|
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage(),
|
||||||
|
{ ...linuxHost, arch: "arm64" },
|
||||||
|
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
// musl reports no glibc version, so the host cannot be proven glibc.
|
||||||
|
const { libc: _libc, ...musl } = linuxHost;
|
||||||
|
await refusedOn(nativePackage(), musl, "MODULE_PACKAGE_TARGET_INCOMPATIBLE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a target this platform does not implement", async () => {
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage({ target: "linux-arm64-glibc" }),
|
||||||
|
{ ...linuxHost, arch: "arm64" },
|
||||||
|
"MODULE_PACKAGE_TARGET_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a C library older than the package needs", async () => {
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage(),
|
||||||
|
{ ...linuxHost, libc: { family: "glibc", version: "2.17" } },
|
||||||
|
"MODULE_PACKAGE_LIBC_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
verifyOn(nativePackage(), { ...linuxHost, libc: { family: "glibc", version: "2.28" } }),
|
||||||
|
).resolves.toMatchObject({ target: "linux-x64-glibc" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a Node-API version or Node.js ABI the host does not provide", async () => {
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage(),
|
||||||
|
{ ...linuxHost, nodeApi: 8 },
|
||||||
|
"MODULE_PACKAGE_NODE_API_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
const { nodeApi: _nodeApi, ...unknownApi } = linuxHost;
|
||||||
|
await refusedOn(nativePackage(), unknownApi, "MODULE_PACKAGE_NODE_API_INCOMPATIBLE");
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20", nodeAbi: "115" } }),
|
||||||
|
linuxHost,
|
||||||
|
"MODULE_PACKAGE_NODE_API_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a native file built for another machine, or an addon that is not one", async () => {
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage({
|
||||||
|
files: { "node_modules/native-thing/lib/native-thing.node": elfHeader(183) },
|
||||||
|
}),
|
||||||
|
linuxHost,
|
||||||
|
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage({
|
||||||
|
files: { "node_modules/native-thing/lib/libthing.so": elfHeader(183) },
|
||||||
|
}),
|
||||||
|
linuxHost,
|
||||||
|
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
await refusedOn(
|
||||||
|
nativePackage({
|
||||||
|
files: { "node_modules/native-thing/lib/native-thing.node": "not an ELF object" },
|
||||||
|
}),
|
||||||
|
linuxHost,
|
||||||
|
"MODULE_PACKAGE_NATIVE_FILE_INCOMPATIBLE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a native package to state its Node.js interface and, on Linux, its C library", async () => {
|
||||||
|
await rejection(
|
||||||
|
nativePackage({ runtime: { libcVersion: ">=2.28.0", node: ">=20" } }),
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
await rejection(
|
||||||
|
nativePackage({ runtime: { node: ">=20", nodeApi: 9 } }),
|
||||||
|
"MODULE_PACKAGE_METADATA_INVALID",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps any-target packages free of node_modules, addons, and ELF objects", async () => {
|
||||||
|
await rejection(
|
||||||
|
{ files: { "node_modules/thing/index.js": "module.exports = {};\n" } },
|
||||||
|
"MODULE_PACKAGE_NATIVE_FILE",
|
||||||
|
);
|
||||||
|
await rejection({ files: { "dist/thing.node": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE");
|
||||||
|
await rejection({ files: { "dist/libthing.so": elfHeader() } }, "MODULE_PACKAGE_NATIVE_FILE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("establishes this host's facts from Node.js alone", () => {
|
||||||
|
const host = currentModulePackageHost();
|
||||||
|
expect(host.platform).toBe(process.platform);
|
||||||
|
expect(host.arch).toBe(process.arch);
|
||||||
|
expect(host.nodeApi).toBe(Number(process.versions.napi));
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
const report = process.report.getReport() as { header?: { glibcVersionRuntime?: string } };
|
||||||
|
expect(host.libc?.version).toBe(report.header?.glibcVersionRuntime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("fails an archive without package metadata", async () => {
|
it("fails an archive without package metadata", async () => {
|
||||||
const fixture = await modulePackageFixture();
|
const fixture = await modulePackageFixture();
|
||||||
const { gunzipSync, gzipSync } = await import("node:zlib");
|
const { gunzipSync, gzipSync } = await import("node:zlib");
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type {
|
import type { ModuleRealtimePublisher } from "@ekklesia/contracts";
|
||||||
ModuleDatabase,
|
import type { LegacyModuleRuntimeServices, ModuleDatabase } from "../src/modules/database.js";
|
||||||
ModuleRealtimePublisher,
|
|
||||||
ModuleRuntimeServices,
|
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
|
import { ModuleRuntimeError, startModuleRuntime } from "../src/modules/runtime.js";
|
||||||
|
|
||||||
@@ -22,7 +19,7 @@ afterEach(async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function fakeServices(): ModuleRuntimeServices {
|
function fakeServices(): LegacyModuleRuntimeServices {
|
||||||
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
return Object.freeze({ database: Object.freeze({}) as ModuleDatabase });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type { ModuleConfiguration, ModuleDatabaseExecutor } from "@ekklesia/contracts";
|
||||||
import type {
|
import type {
|
||||||
AnyModuleRuntimeServices,
|
AnyModuleRuntimeServices,
|
||||||
ModuleConfiguration,
|
LegacyModuleRuntimeServices,
|
||||||
ModuleDatabase,
|
ModuleDatabase,
|
||||||
ModuleDatabaseExecutor,
|
} from "../src/modules/database.js";
|
||||||
ModuleRuntimeServices,
|
|
||||||
} from "@ekklesia/contracts";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { discoverModules } from "../src/modules/discovery.js";
|
import { discoverModules } from "../src/modules/discovery.js";
|
||||||
import {
|
import {
|
||||||
@@ -151,7 +150,7 @@ function fakeModuleDatabase(): ModuleDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
|
function fakeModuleRuntimeServices(): ModuleRuntimeServicesLookup {
|
||||||
const services = new Map<string, ModuleRuntimeServices>();
|
const services = new Map<string, LegacyModuleRuntimeServices>();
|
||||||
const database = fakeModuleDatabase();
|
const database = fakeModuleDatabase();
|
||||||
return new Proxy(services, {
|
return new Proxy(services, {
|
||||||
get(target, property, receiver) {
|
get(target, property, receiver) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
EMPTY_MODULE_STORAGE,
|
EMPTY_MODULE_STORAGE,
|
||||||
|
isModuleStorageError,
|
||||||
isValidModuleStorageKey,
|
isValidModuleStorageKey,
|
||||||
MODULE_STORAGE_KEY_MAX_BYTES,
|
MODULE_STORAGE_KEY_MAX_BYTES,
|
||||||
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
|
MODULE_STORAGE_READ_URL_DEFAULT_SECONDS,
|
||||||
@@ -322,6 +323,17 @@ describe("module storage scoping", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recognizes a storage error from an installed module's separate package copy", () => {
|
||||||
|
const external = Object.assign(new Error("The storage service could not be reached."), {
|
||||||
|
code: "STORAGE_PROVIDER_UNAVAILABLE",
|
||||||
|
name: "ModuleStorageError",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(external).not.toBeInstanceOf(ModuleStorageError);
|
||||||
|
expect(isModuleStorageError(external)).toBe(true);
|
||||||
|
expect(isModuleStorageError(Object.assign(new Error("no"), { code: "UNKNOWN" }))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("gives a module no way to reach another module's storage by knowing its name", async () => {
|
it("gives a module no way to reach another module's storage by knowing its name", async () => {
|
||||||
const shared = fakeProvider();
|
const shared = fakeProvider();
|
||||||
const announcements = createModuleStorage({
|
const announcements = createModuleStorage({
|
||||||
|
|||||||
@@ -1905,7 +1905,7 @@ describe.sequential("live PostgreSQL migration timeout precedence", () => {
|
|||||||
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
|
describe.sequential("live PostgreSQL retained history for an uninstalled module", () => {
|
||||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
const templateModule = path.join(repositoryRoot, "templates", "module");
|
const templateModule = path.join(repositoryRoot, "templates", "module");
|
||||||
const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const;
|
const versions = { moduleSdkVersion: "0.2.0", platformVersion: "0.2.0" } as const;
|
||||||
|
|
||||||
async function coreWithoutNewestMigration(directory: string): Promise<string> {
|
async function coreWithoutNewestMigration(directory: string): Promise<string> {
|
||||||
const target = path.join(directory, "core-drizzle");
|
const target = path.join(directory, "core-drizzle");
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* The per-test timeout for Core's import-safety tests, and only those.
|
||||||
|
*
|
||||||
|
* Each of these tests proves that importing a module does nothing (no I/O, no connection, no output)
|
||||||
|
* by performing a cold dynamic `import()` of a large Core source graph inside the test body. Vitest
|
||||||
|
* transforms and evaluates that whole graph on demand, and that work is CPU-bound. An ordinary test
|
||||||
|
* file's own top-level imports run before its tests start and fall under no per-test timeout; these
|
||||||
|
* deliberately do not, so the cost lands inside Vitest's default 5-second test timeout.
|
||||||
|
*
|
||||||
|
* Alone they take about 0.7–1.2 s. During a full `pnpm verify`, Turbo runs every package's suite at
|
||||||
|
* once and the CPU is saturated. Reproduced locally (six cores, Core's suite alongside five other
|
||||||
|
* packages' suites), the same tests took 3.0–5.4 s, and so timed out intermittently.
|
||||||
|
*
|
||||||
|
* 30 seconds is about five times the slowest time measured under load, which leaves room for slower
|
||||||
|
* shared CI runners. It matches the repository's integration-test timeout. It is still bounded, so a
|
||||||
|
* module that genuinely hangs while being imported, for example on a network call, still fails. The
|
||||||
|
* assertions themselves are unchanged: these tests fail on what an import does, never on speed.
|
||||||
|
*/
|
||||||
|
export const IMPORT_SAFETY_TEST_TIMEOUT_MS = 30_000;
|
||||||
@@ -54,7 +54,7 @@ export function manifestFor(
|
|||||||
id,
|
id,
|
||||||
name: `${id} module`,
|
name: `${id} module`,
|
||||||
version: "0.1.0",
|
version: "0.1.0",
|
||||||
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
compatibility: { platform: "^0.2.0", moduleSdk: "^0.2.0" },
|
||||||
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
...(options.dependencies ? { dependencies: options.dependencies } : {}),
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export interface PackageFixtureOptions {
|
|||||||
readonly tamper?: (files: Map<string, Buffer>) => void;
|
readonly tamper?: (files: Map<string, Buffer>) => void;
|
||||||
/** Entries appended after the package's own, under any path. */
|
/** Entries appended after the package's own, under any path. */
|
||||||
readonly extraEntries?: readonly FixtureEntry[];
|
readonly extraEntries?: readonly FixtureEntry[];
|
||||||
|
/** Extra payload files, listed in the metadata like every other file. */
|
||||||
|
readonly files?: Readonly<Record<string, Uint8Array | string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackageFixture {
|
export interface PackageFixture {
|
||||||
@@ -162,6 +164,10 @@ export async function modulePackageFixture(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
|
files.set("THIRD_PARTY_NOTICES", Buffer.from("No third-party software is included.\n"));
|
||||||
|
files.set("LICENSE", Buffer.from("Example module license text.\n"));
|
||||||
|
for (const [filePath, data] of Object.entries(options.files ?? {})) {
|
||||||
|
files.set(filePath, Buffer.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
const listed = [...files.entries()]
|
const listed = [...files.entries()]
|
||||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
@@ -169,13 +175,14 @@ export async function modulePackageFixture(
|
|||||||
const baseMetadata: Record<string, unknown> = {
|
const baseMetadata: Record<string, unknown> = {
|
||||||
packageFormat: 1,
|
packageFormat: 1,
|
||||||
module: { id, version },
|
module: { id, version },
|
||||||
|
license: "MIT",
|
||||||
target: "any",
|
target: "any",
|
||||||
runtime: { node: ">=20" },
|
runtime: { node: ">=20" },
|
||||||
provenance: {
|
provenance: {
|
||||||
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
|
revision: "86ae907f59458508e3277666cdd214f5bd7c1078",
|
||||||
packager: { name: "@ekklesia/module-pack", version: "0.1.2" },
|
packager: { name: "@ekklesia/module-pack", version: "0.2.0" },
|
||||||
},
|
},
|
||||||
incorporates: { "@ekklesia/module-sdk": "0.1.2" },
|
incorporates: { "@ekklesia/module-sdk": "0.2.0" },
|
||||||
files: listed,
|
files: listed,
|
||||||
};
|
};
|
||||||
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
|
const metadata = options.metadata ? options.metadata(baseMetadata) : baseMetadata;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
MODULE_VERIFIER_SCRATCH_ENV,
|
||||||
|
moduleVerifierScratchDirectory,
|
||||||
parseSha256File,
|
parseSha256File,
|
||||||
parseVerifyModulePackageArguments,
|
parseVerifyModulePackageArguments,
|
||||||
readModulePackagePlatform,
|
readModulePackagePlatform,
|
||||||
@@ -57,6 +59,21 @@ describe("verify-module-package digest files", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("verify-module-package execution scratch", () => {
|
||||||
|
it("accepts an absolute operator-configured directory and otherwise leaves the library default", () => {
|
||||||
|
expect(moduleVerifierScratchDirectory({})).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: "/run/ekklesia-verifier" }),
|
||||||
|
).toBe("/run/ekklesia-verifier");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["", "relative/path"])("refuses the invalid configured path %j", (configured) => {
|
||||||
|
expect(() =>
|
||||||
|
moduleVerifierScratchDirectory({ [MODULE_VERIFIER_SCRATCH_ENV]: configured }),
|
||||||
|
).toThrow(`${MODULE_VERIFIER_SCRATCH_ENV} must be an absolute path.`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("verify-module-package platform facts", () => {
|
describe("verify-module-package platform facts", () => {
|
||||||
it("reads this platform's versions and its transitional database runtime from the repository", async () => {
|
it("reads this platform's versions and its transitional database runtime from the repository", async () => {
|
||||||
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
|
const root = JSON.parse(await readFile(path.join(repositoryRoot, "package.json"), "utf8"));
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
"extends": "../packages/typescript-config/node.json",
|
"extends": "../packages/typescript-config/node.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"types": ["node", "vitest/globals"]
|
"types": ["node", "vitest/globals"],
|
||||||
|
"rewriteRelativeImportExtensions": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"drizzle.config.ts",
|
"drizzle.config.ts",
|
||||||
|
|||||||
+15
-3
@@ -17,6 +17,7 @@ specific to one organization.
|
|||||||
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
|
| [`scripts/init-tls.sh`](scripts/init-tls.sh) | Creates the installation-private CA and the `postgres` and `object-storage` server certificates. |
|
||||||
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
|
| [`scripts/gen-secrets.sh`](scripts/gen-secrets.sh) | Prints freshly generated secrets to paste into the env files. Writes nothing. |
|
||||||
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
|
| [`Dockerfile`](Dockerfile) | The one production image, for the API and every operator command ([The production image](#the-production-image)). |
|
||||||
|
| [`display-kiosk.md`](display-kiosk.md) | Setting up an unattended display screen: launch, pairing, and recovery. |
|
||||||
|
|
||||||
## The reference deployment
|
## The reference deployment
|
||||||
|
|
||||||
@@ -170,9 +171,20 @@ identity, target, and Node.js range against this platform, runs the platform's m
|
|||||||
validation, and loads the backend once in a short-lived process with no environment. Only then does
|
validation, and loads the backend once in a short-lived process with no environment. Only then does
|
||||||
it write the verified module directory, to a new directory outside module storage:
|
it write the verified module directory, to a new directory outside module storage:
|
||||||
|
|
||||||
|
The backend probe executes the package's server-side code; the verifier container is its isolation
|
||||||
|
boundary. Keep the general-purpose `/tmp` mount `noexec`, and give only this one-shot verifier a
|
||||||
|
separate executable scratch tmpfs. The mount below is ephemeral, bounded at 1100 MiB (just above the
|
||||||
|
package format's 1 GiB expanded limit), `nodev`, `nosuid`, and writable only by the image's
|
||||||
|
unprivileged `node` user. `EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR` tells the CLI where that mount is;
|
||||||
|
the verifier creates a unique child for each run and removes it on success or failure.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
|
mkdir -m 777 staging # writable by the image's unprivileged user; nothing else is
|
||||||
docker run --rm --network none --read-only --tmpfs /tmp \
|
docker run --rm --network none --read-only \
|
||||||
|
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
|
||||||
|
--tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \
|
||||||
|
--cap-drop ALL --security-opt no-new-privileges \
|
||||||
|
-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \
|
||||||
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
|
-v "$PWD:/package:ro" -v "$PWD/staging:/staging" \
|
||||||
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
|
--entrypoint node "$EKKLESIA_IMAGE" core/dist/cli/verify-module-package.js \
|
||||||
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
|
/package/<id>-<version>-any.tar.gz --sha256-file /package/<id>-<version>-any.tar.gz.sha256 \
|
||||||
@@ -500,10 +512,10 @@ finds its files:
|
|||||||
```sh
|
```sh
|
||||||
docker build \
|
docker build \
|
||||||
--file deploy/Dockerfile \
|
--file deploy/Dockerfile \
|
||||||
--build-arg VERSION=0.1.2 \
|
--build-arg VERSION=0.2.5 \
|
||||||
--build-arg REVISION="$(git rev-parse HEAD)" \
|
--build-arg REVISION="$(git rev-parse HEAD)" \
|
||||||
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
--build-arg SOURCE=https://git.example.org/your-org/ekklesia \
|
||||||
--tag ekklesia:0.1.2 \
|
--tag ekklesia:0.2.5 \
|
||||||
.
|
.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ services:
|
|||||||
command:
|
command:
|
||||||
- mini
|
- mini
|
||||||
- -dir=/data
|
- -dir=/data
|
||||||
|
# A bucket is a SeaweedFS collection and claims a block of volumes. The ~1 GB automatic
|
||||||
|
# volume size leaves modest hosts with room for only a few collections; after that, writes
|
||||||
|
# stall instead of failing. This changes allocation granularity, not object or total capacity.
|
||||||
|
- -master.volumeSizeLimitMB=32
|
||||||
- -s3.port.https=8443
|
- -s3.port.https=8443
|
||||||
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
- -s3.cert.file=/run/ekklesia/tls/object-storage.crt
|
||||||
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
- -s3.key.file=/run/ekklesia/tls/object-storage.key
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Setting up a display kiosk
|
||||||
|
|
||||||
|
A display is a screen — a television with a small computer, or a mini PC on a wall — that opens one
|
||||||
|
module's display page full screen and keeps it open unattended. This guide sets one up so it starts
|
||||||
|
on boot, stays paired, recovers from network and server outages on its own, and never opens a second
|
||||||
|
copy of itself.
|
||||||
|
|
||||||
|
Pairing and display identity belong to the platform (ADR 0036). Today an administrator approves a
|
||||||
|
screen in the Dashboard sidebar's **Display devices** panel; a later release moves this to
|
||||||
|
**Settings → Displays**. Nothing below changes when that happens.
|
||||||
|
|
||||||
|
## What the screen needs
|
||||||
|
|
||||||
|
- Chromium or Google Chrome, current stable. The display page is tested against Chromium.
|
||||||
|
- A **dedicated, persistent browser profile directory**. The device credential is an HttpOnly cookie
|
||||||
|
in that profile; it is what keeps the screen paired across reboots. Never use a guest, incognito, or
|
||||||
|
"clear data on exit" profile, and never share the profile with a person's browsing.
|
||||||
|
- Network access to the installation's **API hostname** (the display page, its realtime stream, and
|
||||||
|
its module requests) and to the storage hostname that serves images.
|
||||||
|
- A clock that is roughly right. Playback follows the server's clock, but TLS does not tolerate a
|
||||||
|
wildly wrong one.
|
||||||
|
|
||||||
|
## The address
|
||||||
|
|
||||||
|
Every display-capable module has one display page on the API hostname:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://<api-hostname>/display/<module-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
For Signage that is `https://<api-hostname>/display/signage`. The Signage **Displays** tab shows the
|
||||||
|
exact address under **Copy setup address**. Open it on the screen itself; opening it on an
|
||||||
|
administrator's own computer only previews the display (and that browser appears in the roster as a
|
||||||
|
*Signed-in browser*, which can be removed once it is offline).
|
||||||
|
|
||||||
|
## Launching the browser
|
||||||
|
|
||||||
|
Run exactly **one** launcher, and launch the page as an app window with a fixed profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chromium \
|
||||||
|
--user-data-dir=/var/lib/display-kiosk/profile \
|
||||||
|
--app=https://<api-hostname>/display/signage \
|
||||||
|
--kiosk \
|
||||||
|
--no-first-run \
|
||||||
|
--noerrdialogs \
|
||||||
|
--disable-session-crashed-bubble \
|
||||||
|
--hide-crash-restore-bubble \
|
||||||
|
--disable-features=Translate \
|
||||||
|
--autoplay-policy=no-user-gesture-required \
|
||||||
|
--check-for-update-interval=31536000
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--app` opens the page with no tab strip and no address bar. `--kiosk` makes it full screen.
|
||||||
|
- `--user-data-dir` is the persistent profile that holds the pairing. Keep it on persistent storage
|
||||||
|
and back it up with nothing: if it is lost, the screen simply shows a new pairing code.
|
||||||
|
- Do **not** add `--restore-last-session`, and do not also start the browser from a desktop autostart
|
||||||
|
entry. Chromium allows one process per profile; a second launch hands its URL to the running
|
||||||
|
process, which opens a **second window or tab** of the display. That is the usual cause of
|
||||||
|
"duplicate" displays. One launcher, one profile, one window.
|
||||||
|
|
||||||
|
### Start on boot and restart on exit (systemd)
|
||||||
|
|
||||||
|
Run the browser as an unprivileged user in that user's graphical session, from a single user unit:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# ~/.config/systemd/user/display-kiosk.service
|
||||||
|
[Unit]
|
||||||
|
Description=Display kiosk
|
||||||
|
After=graphical-session.target network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/chromium --user-data-dir=%h/.local/share/display-kiosk --app=https://<api-hostname>/display/signage --kiosk --no-first-run --noerrdialogs --disable-session-crashed-bubble --hide-crash-restore-bubble --disable-features=Translate --autoplay-policy=no-user-gesture-required --check-for-update-interval=31536000
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now display-kiosk.service
|
||||||
|
loginctl enable-linger <kiosk-user> # with the desktop set to log this user in automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
`systemctl --user stop display-kiosk` closes the browser cleanly, which is what keeps its profile —
|
||||||
|
and the pairing cookie — intact. Avoid cutting power to a running kiosk when you can: a clean shutdown
|
||||||
|
is always safer for a browser profile.
|
||||||
|
|
||||||
|
## Pairing
|
||||||
|
|
||||||
|
1. Start the kiosk. With no credential it shows a **six-digit pairing code**.
|
||||||
|
2. In the Dashboard, enter the code under **Display devices**, give the screen a name, and approve.
|
||||||
|
3. Within a few seconds the screen collects its credential and starts showing content.
|
||||||
|
4. In Signage → **Displays**, name the display and choose its group and audience.
|
||||||
|
|
||||||
|
A paired screen stays paired across browser restarts and reboots, and for as long as it is in use:
|
||||||
|
its credential is renewed each time it checks in, and only expires after **180 days without being
|
||||||
|
used** (ADR 0056). A screen that is switched off and stored for longer than that, or that is revoked,
|
||||||
|
returns to its pairing code and is paired again the same way.
|
||||||
|
|
||||||
|
## What happens when things go wrong
|
||||||
|
|
||||||
|
| Event | What the screen does |
|
||||||
|
| --- | --- |
|
||||||
|
| Network drops for seconds | Nothing visible. The display keeps running; the realtime stream reconnects. |
|
||||||
|
| Network or API down for longer | Keeps playing what it has cached, with a small corner note; recovers by itself when the server is back. No remount, no reload. |
|
||||||
|
| API restarted or upgraded | Reconnects by itself. A new module version is picked up within five minutes and swapped in. |
|
||||||
|
| Browser crashes or is restarted | systemd restarts it; the same profile is still paired. |
|
||||||
|
| Machine reboots | Starts on boot and resumes. If the server is not up yet, the screen retries on its own (every few seconds, backing off to once a minute) until it is. |
|
||||||
|
| Display revoked in the Dashboard | Stops showing content within seconds and returns to its pairing code. |
|
||||||
|
| Display moved to another group or audience | Switches content within about one heartbeat (≈12 s), no reload. |
|
||||||
|
|
||||||
|
**Offline cold start is not supported yet.** A screen that is already showing content survives an
|
||||||
|
outage. A screen that *boots* while the server is completely unreachable can load its cached page but
|
||||||
|
cannot mount the module until the server answers, because the authorization it needs is never cached
|
||||||
|
(ADR 0043, Signage ADR 0006). It shows a short message and retries on its own until the server is
|
||||||
|
reachable — nobody needs to touch it.
|
||||||
|
|
||||||
|
## Checklist for a new kiosk
|
||||||
|
|
||||||
|
- [ ] One systemd unit, no desktop autostart entry, no second launcher.
|
||||||
|
- [ ] `--user-data-dir` on persistent storage, not shared, not cleared on exit.
|
||||||
|
- [ ] Automatic login of the kiosk user, and screen blanking/power saving disabled.
|
||||||
|
- [ ] Paired and named; assigned in Signage.
|
||||||
|
- [ ] Unplug the network for a minute: content keeps playing, then the note clears on reconnect.
|
||||||
|
- [ ] Reboot: the screen comes back paired without anyone touching it.
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
- Status: Accepted
|
- Status: Accepted
|
||||||
- Date: 2026-08-24
|
- Date: 2026-08-24
|
||||||
|
- Session lifetime superseded by: [0056](0056-device-credentials-last-while-in-use.md)
|
||||||
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
|
- Extends: [0013](0013-core-authorization-and-owner-bootstrap.md),
|
||||||
[0032](0032-native-module-realtime-transport.md),
|
[0032](0032-native-module-realtime-transport.md),
|
||||||
[0033](0033-display-kiosk-host-contract.md),
|
[0033](0033-display-kiosk-host-contract.md),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 0051: Module packages declare their own license and carry its text
|
# 0051: Module packages declare their own license and carry its text
|
||||||
|
|
||||||
- Status: Proposed
|
- Status: Accepted
|
||||||
- Date: 2026-10-07
|
- Date: 2026-10-07
|
||||||
- Amends: [0049](0049-module-package-v1.md) — every Module Package v1 artifact gains an
|
- Amends: [0049](0049-module-package-v1.md) — every Module Package v1 artifact gains an
|
||||||
authoritative package-level license declaration and a `LICENSE` payload. `packageFormat` stays 1.
|
authoritative package-level license declaration and a `LICENSE` payload. `packageFormat` stays 1.
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# 0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-07
|
||||||
|
- Extends: [0049](0049-module-package-v1.md), in one respect only. ADR 0049 left exactly how licence
|
||||||
|
and notice material is collected to the packager's implementation. That implementation, and the
|
||||||
|
module development guide, then took material only from a redistributed package's own files, or
|
||||||
|
from a predefined standard licence text that needs no project-specific lines. This ADR adds one
|
||||||
|
further source: a reviewed supplement. Everything else in ADR 0049 is unchanged, as is
|
||||||
|
`packageFormat`.
|
||||||
|
- Related: [ADR 0048](0048-generic-image-and-installed-module-storage.md),
|
||||||
|
[ADR 0051](0051-module-package-license-material.md),
|
||||||
|
[ADR 0055](0055-corresponding-source-artifacts.md) (decides the source-obligation policy deferred
|
||||||
|
below)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0049 requires every Module Package v1 to carry `THIRD_PARTY_NOTICES`: the licence and notice
|
||||||
|
material that redistributing its third-party code requires. It also states that no package is a
|
||||||
|
distributable release until licence and notice collection is proven. The canonical packager collects
|
||||||
|
material from two places:
|
||||||
|
|
||||||
|
- the licence, copying, copyright, and notice files a redistributed package ships at its root;
|
||||||
|
- the standard text of the package's declared licence, but only when that text is complete without
|
||||||
|
project-specific lines (Apache-2.0 today).
|
||||||
|
|
||||||
|
A package that has neither stops packaging. That is fail-closed by design: an MIT or BSD package
|
||||||
|
without its file is refused, because its copyright line exists only in that file.
|
||||||
|
|
||||||
|
The first native-target module package needs a dependency that this rule cannot handle.
|
||||||
|
`@img/[email protected]` is the prebuilt libvips that `sharp` loads on Linux x64 with
|
||||||
|
glibc. It declares `LGPL-3.0-or-later`. The package itself, like its latest release 1.3.4, contains
|
||||||
|
only these files:
|
||||||
|
- `README.md`
|
||||||
|
- `versions.json`
|
||||||
|
- `lib/index.js`
|
||||||
|
- one glib header
|
||||||
|
- one shared library
|
||||||
|
|
||||||
|
That shared library includes about 29 libraries. Its README names each one and the licence it is
|
||||||
|
used under, mostly with a link:
|
||||||
|
- LGPL components, such as libvips, glib, and pango;
|
||||||
|
- cairo, under MPL-2.0;
|
||||||
|
- many BSD-, MIT-, zlib-, IJG-, and FreeType-licensed libraries.
|
||||||
|
|
||||||
|
The package ships no licence text and no copyright notice for any of them. No standard text can stand
|
||||||
|
in for that material: it includes per-project copyright notices and several different licences.
|
||||||
|
|
||||||
|
Options that were ruled out before this decision:
|
||||||
|
- **Downloading material while packaging.** Packaging must stay offline and deterministic (ADR 0049,
|
||||||
|
ADR 0051).
|
||||||
|
- **Inventing material.** A packager that guesses notices produces artifacts that look compliant and
|
||||||
|
may not be.
|
||||||
|
|
||||||
|
Ekklesia needs a way to carry material that a dependency's own package omits. It has to be reviewed
|
||||||
|
by people, tied to exactly the bytes it was reviewed for, and fail closed whenever that tie breaks.
|
||||||
|
|
||||||
|
Some of these licences may also impose obligations beyond carrying text: the LGPL and the MPL can
|
||||||
|
require recipients to be able to obtain corresponding source. Whether and how such an obligation is
|
||||||
|
met for a particular redistributed binary is a legal and compliance determination. This decision
|
||||||
|
does not make that determination.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Package-provided material stays the preferred source
|
||||||
|
|
||||||
|
The licence, copying, copyright, and notice material that a redistributed package ships remains the
|
||||||
|
primary source, collected exactly as it is today. Nothing in this ADR removes, replaces, or rewrites
|
||||||
|
package-provided material. A supplement only adds to it.
|
||||||
|
|
||||||
|
### A reviewed supplement fills what a package omits
|
||||||
|
|
||||||
|
When a redistributed dependency omits material that redistribution requires, the canonical Ekklesia
|
||||||
|
packager may use a **license supplement**: a checked-in, reviewed record of that material for one
|
||||||
|
exact dependency.
|
||||||
|
|
||||||
|
- **Where supplements live.** In the Ekklesia repository, as part of the canonical packager's own
|
||||||
|
versioned inputs. A supplement enters only through Ekklesia's normal reviewed change process, and
|
||||||
|
the change that adds it is its review. A given packager version therefore always carries the same
|
||||||
|
supplements.
|
||||||
|
- **What that review is.** Repository review of a supplement is project and code review of a build
|
||||||
|
input: whether its content, its exact match, and its provenance are what the change says they are.
|
||||||
|
It is not legal review, and it does not prove that any redistribution obligation is satisfied.
|
||||||
|
- **What a supplement may contain:**
|
||||||
|
- licence texts;
|
||||||
|
- copyright and attribution notices;
|
||||||
|
- provenance: where each piece of material was obtained, and which upstream component versions it
|
||||||
|
covers;
|
||||||
|
- corresponding-source information, such as where upstream source for the exact build is
|
||||||
|
published;
|
||||||
|
- other material that accompanies the dependency's redistribution.
|
||||||
|
- **Generic by construction.** Supplements are data. The mechanism contains no package-specific code
|
||||||
|
and treats every supplement identically, whether it is for libvips or for any other dependency.
|
||||||
|
|
||||||
|
### A supplement matches one exact package, or it fails closed
|
||||||
|
|
||||||
|
A supplement names exactly one redistributed package:
|
||||||
|
|
||||||
|
- its package name;
|
||||||
|
- its exact version;
|
||||||
|
- an integrity value for the package's contents: a digest the packager computes from the package's
|
||||||
|
redistributed files.
|
||||||
|
- **Deterministic.** It covers exactly each redistributed file's normalized relative path within
|
||||||
|
the package and its bytes, combined in an order defined by the paths themselves.
|
||||||
|
- **Host-independent.** It excludes filesystem metadata, timestamps, ownership, permissions, and
|
||||||
|
the order in which files were extracted or listed. The same package contents always give the
|
||||||
|
same digest, wherever and however they were installed.
|
||||||
|
- **Documented.** The implementation defines and documents the exact algorithm, which does not
|
||||||
|
depend on any package manager.
|
||||||
|
|
||||||
|
The packager uses a supplement only when all three match the package it is about to redistribute.
|
||||||
|
|
||||||
|
- **A mismatch on any of the three fails packaging.** This covers, for example, a supplement whose
|
||||||
|
name and version match but whose integrity differs. It never falls back to a nearby version, and
|
||||||
|
it never silently skips the supplement.
|
||||||
|
- **Every match is used.** A supplement that matches is always merged into the notices, including
|
||||||
|
when the package also ships some material of its own.
|
||||||
|
- **Gaps still fail.** If a dependency lacks required material and no exact supplement matches, the
|
||||||
|
packager stops, exactly as it does today.
|
||||||
|
|
||||||
|
### Supplements are immutable build inputs
|
||||||
|
|
||||||
|
- **Immutable.** A supplement is never edited in place to cover a different build. A changed
|
||||||
|
package, a new version, or different contents need a new supplement, reviewed again.
|
||||||
|
- **No network.** Packaging reads supplements from the packager's own inputs and never downloads
|
||||||
|
anything. Assembling a supplement's content is a separate, offline-reviewed curation step done
|
||||||
|
when the supplement is authored, never during packaging.
|
||||||
|
|
||||||
|
### Merging is deterministic and labelled
|
||||||
|
|
||||||
|
The notice generator merges package-provided material and supplement material deterministically. The
|
||||||
|
same packager version and the same inputs still produce a byte-identical `THIRD_PARTY_NOTICES`
|
||||||
|
(ADR 0049).
|
||||||
|
|
||||||
|
- **Order.** Within a dependency's entry, package-provided material comes first, then supplement
|
||||||
|
material.
|
||||||
|
- **Labelling.** Supplement material is marked as supplied by Ekklesia's reviewed supplement, never
|
||||||
|
presented as if it came from the package.
|
||||||
|
- **No other change.** Dependencies are still sorted, line endings normalized, and each dependency
|
||||||
|
keeps its own entry, exactly as before.
|
||||||
|
|
||||||
|
### A supplement never changes a dependency's declared licence
|
||||||
|
|
||||||
|
`THIRD_PARTY_NOTICES` keeps reporting the licence the dependency itself declares.
|
||||||
|
|
||||||
|
- **No override.** A supplement can add material about the components a dependency contains, but it
|
||||||
|
cannot replace, override, or rewrite the dependency's declared licence.
|
||||||
|
- **Consistency check.** A supplement records the declared licence it was reviewed against. If that
|
||||||
|
disagrees with the package's actual declaration, packaging fails.
|
||||||
|
|
||||||
|
### Legal and compliance determinations are not made here
|
||||||
|
|
||||||
|
A supplement can record corresponding-source information, but this ADR does not decide what
|
||||||
|
satisfies any licence's source obligation, and neither does the mechanism:
|
||||||
|
- the mechanism never asserts that a written offer, a source URL, a mirror, or any other arrangement
|
||||||
|
satisfies a licence;
|
||||||
|
- carrying a supplement shows that the reviewed material is present in the package. It does not
|
||||||
|
certify that the package is compliant;
|
||||||
|
- for every redistributed binary under the LGPL, the MPL, or any other licence with source or
|
||||||
|
similar obligations, the determination of whether and how those obligations are met is deferred.
|
||||||
|
It must be made, and verified, separately.
|
||||||
|
|
||||||
|
ADR 0049's release gate still applies. A package that redistributes such a binary does not become a
|
||||||
|
distributable release just because its supplement was merged. It becomes one only once that
|
||||||
|
binary's compliance determination has been made and verified.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- **Third-party code only.** Supplements apply only to redistributed third-party dependencies,
|
||||||
|
whether bundled or copied into the package. They never apply to the module's own licence or
|
||||||
|
`LICENSE` (ADR 0051), or to incorporated Ekklesia packages.
|
||||||
|
- **No package format change.** `packageFormat` stays **1**. A supplement changes only the content of
|
||||||
|
`THIRD_PARTY_NOTICES`, which is already a listed, digested payload file. No metadata field, archive
|
||||||
|
rule, or verifier contract changes.
|
||||||
|
|
||||||
|
### The motivating case
|
||||||
|
|
||||||
|
`@img/[email protected]` is the first dependency expected to need a supplement. This ADR
|
||||||
|
does not curate it. Its supplement will be assembled, reviewed, and merged separately, together with
|
||||||
|
the native-target packaging work that redistributes it. The source-obligation determination for its
|
||||||
|
LGPL and MPL components remains open, as above.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A dependency whose package omits required material can be redistributed after review, offline and
|
||||||
|
deterministically, instead of making native packaging impossible.
|
||||||
|
- Each supplement is tied to exact package bytes, so a changed or unexpected dependency fails
|
||||||
|
packaging rather than inheriting material reviewed for something else.
|
||||||
|
- Ekklesia takes on reviewing and maintaining supplements. Every version bump of a supplemented
|
||||||
|
dependency needs a new reviewed supplement before packaging works again.
|
||||||
|
- `THIRD_PARTY_NOTICES` can now contain material that did not come from the dependency's package. It
|
||||||
|
is always labelled.
|
||||||
|
- Carrying material and meeting obligations stay separate. Source-availability questions remain open
|
||||||
|
until they are determined per binary.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Download licence material while packaging.** Rejected: packaging must stay offline,
|
||||||
|
deterministic, and independent of a mutable remote source (ADR 0049, ADR 0051).
|
||||||
|
- **Extend the standard-text list.** Rejected: standard texts only work when the licence needs no
|
||||||
|
project-specific line. Bundled native libraries need per-project copyright notices, and several
|
||||||
|
different licences.
|
||||||
|
- **Match supplements by name and version alone.** Rejected: identical versions can differ in content
|
||||||
|
across registries or rebuilds, and the material was reviewed for specific bytes.
|
||||||
|
- **Fall back to the nearest supplemented version.** Rejected: material reviewed for one build says
|
||||||
|
nothing reliable about another.
|
||||||
|
- **Let a supplement restate a dependency's licence.** Rejected: the declaration belongs to the
|
||||||
|
dependency's publisher. A supplement that could override it could misrepresent what is being
|
||||||
|
redistributed.
|
||||||
|
- **Per-package special cases in the packager** (for example, code that knows about libvips).
|
||||||
|
Rejected: every supplemented dependency would need new code, and review would be split between
|
||||||
|
code and data.
|
||||||
|
- **Treat a recorded source URL or written offer as satisfying copyleft obligations.** Rejected for
|
||||||
|
this ADR: that is a legal determination that has to be verified per binary, not assumed by a build
|
||||||
|
mechanism.
|
||||||
|
- **Supplements provided by module authors.** Not adopted now: the canonical packager uses only
|
||||||
|
supplements reviewed into Ekklesia. Whether module repositories may contribute their own reviewed
|
||||||
|
supplements is deferred.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Implementing the mechanism in the canonical packager: the supplement format and storage location,
|
||||||
|
the integrity algorithm, the matching, the merge, and the tests.
|
||||||
|
- Curating and reviewing the supplement for `@img/[email protected]`.
|
||||||
|
- The legal and compliance determination of source obligations for each redistributed binary, and
|
||||||
|
how a determination is recorded.
|
||||||
|
- Whether the packager should refuse, or flag, packages whose supplements note an open determination.
|
||||||
|
- Supplements provided by module repositories.
|
||||||
|
- Recording which supplements were used in `ekklesia.package.json`, should that ever be needed.
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# 0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-07
|
||||||
|
- Superseded in part by: [0054](0054-public-package-reads-before-launch.md) — only where this ADR
|
||||||
|
describes the package phase before launch as private, or requires authenticated package reads.
|
||||||
|
- Extends: [0049](0049-module-package-v1.md). This is the step ADR 0049 deferred: "publishing the
|
||||||
|
Ekklesia SDK packages for module builds outside a platform workspace". A registry is used only to
|
||||||
|
build a module. Installing one stays registry-free: an installed package is self-contained, as ADR
|
||||||
|
0049 decided.
|
||||||
|
- Related: [ADR 0002](0002-pnpm-workspace-and-typescript.md),
|
||||||
|
[ADR 0006](0006-versioned-capabilities-and-compiled-module-runtime.md),
|
||||||
|
[ADR 0021](0021-modules-as-hostable-products.md),
|
||||||
|
[ADR 0023](0023-module-ui-build-pipeline.md),
|
||||||
|
[ADR 0039](0039-shared-design-tokens-and-elements.md),
|
||||||
|
[ADR 0047](0047-module-ci-harness.md),
|
||||||
|
[ADR 0048](0048-generic-image-and-installed-module-storage.md),
|
||||||
|
[ADR 0050](0050-module-database-abi-v1.md),
|
||||||
|
[ADR 0051](0051-module-package-license-material.md),
|
||||||
|
[ADR 0052](0052-reviewed-license-supplements.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Modules are independently versioned products in their own repositories (ADR 0021). Yet no module can
|
||||||
|
be built outside Ekklesia's monorepo today. Signage's continuous integration clones the platform at
|
||||||
|
a pinned commit, places Signage under `modules/signage`, and installs and builds inside that
|
||||||
|
workspace, because its Ekklesia dependencies exist only as `workspace:*` packages.
|
||||||
|
|
||||||
|
An investigation of the current packages found the following.
|
||||||
|
|
||||||
|
- **One coordinated version.** Every `@ekklesia/*` package, and the platform, is version 0.1.2. The
|
||||||
|
release process already bumps them together. The platform reports its Module SDK version, which
|
||||||
|
manifests' `compatibility.moduleSdk` ranges are checked against (ADR 0006), by reading
|
||||||
|
`@ekklesia/module-sdk`'s own package version.
|
||||||
|
- **The tarballs already stand alone.** `pnpm pack` rewrites `workspace:*` ranges to exact versions,
|
||||||
|
and each tarball carries only its built `dist`, declarations, and `LICENSE`. But every package's
|
||||||
|
`prepack` runs a command at the monorepo root, and `@ekklesia/typescript-config` is marked
|
||||||
|
`private`.
|
||||||
|
- **The SDK exposes everything.** `@ekklesia/module-sdk` re-exports the whole `@ekklesia/contracts`
|
||||||
|
barrel. That includes contracts no module needs, such as authorization and identity, and the
|
||||||
|
legacy Drizzle `ModuleDatabase` type. `@ekklesia/contracts` depends on `drizzle-orm` for that type,
|
||||||
|
so every module install pulls Drizzle in.
|
||||||
|
- Neither the module template nor Signage imports `@ekklesia/contracts` directly.
|
||||||
|
- **Frameworks are declared twice.** `@ekklesia/ui` lists `react`, and `@ekklesia/module-sdk` lists
|
||||||
|
`drizzle-orm`, as both a dependency and a peer dependency. That invites a second copy of a runtime
|
||||||
|
that must be single within one module bundle.
|
||||||
|
- **The canonical packager assumes the monorepo**, in two ways:
|
||||||
|
- it finds the module's Node.js range only through an enclosing `pnpm-workspace.yaml`;
|
||||||
|
- it decides that an Ekklesia package is the platform's own, not third-party, because its real
|
||||||
|
path does not contain `node_modules`.
|
||||||
|
|
||||||
|
An `@ekklesia/*` package installed from a registry would therefore be treated as third-party.
|
||||||
|
Packaging would omit it from `incorporates` and skip the Module SDK compatibility check.
|
||||||
|
- **The registry and its hosts.**
|
||||||
|
- The current private host is a Gitea 1.27.2 server. Its npm registry is enabled for the
|
||||||
|
organization, and that endpoint and the repositories are currently readable anonymously.
|
||||||
|
- pnpm leaves a tarball URL out of its lockfile only when the URL has the canonical
|
||||||
|
`<registry>/<name>/-/<name>-<version>.tgz` form. Gitea advertises another form, so lockfiles
|
||||||
|
produced against Gitea name the Gitea host.
|
||||||
|
- The integrity hashes in those lockfiles are independent of the host.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### The npm registry protocol, not a provider
|
||||||
|
|
||||||
|
Ekklesia's module-development packages are distributed over the **standard npm registry protocol**,
|
||||||
|
and modules install them with ordinary pnpm or npm commands.
|
||||||
|
|
||||||
|
- **No provider is part of the architecture.** Not Gitea, GitHub, npmjs, or any other. A module
|
||||||
|
repository depends on the protocol and on a scope-to-registry mapping in normal npm/pnpm
|
||||||
|
configuration.
|
||||||
|
- **Gitea may host the private phase.** It is a host for this phase, not a permanent decision.
|
||||||
|
- **No host-neutral lockfile or Ekklesia proxy is required.** Moving to another registry may need a
|
||||||
|
one-time change of a module's registry configuration and a regeneration of its lockfile. The
|
||||||
|
integrity hashes keep verifying the package content across such a move.
|
||||||
|
|
||||||
|
A module repository's registry configuration is, for example:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# .npmrc, committed: where the @ekklesia scope lives, and nothing secret.
|
||||||
|
@ekklesia:registry=https://<registry endpoint>/
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# User or CI configuration, never committed: the credential, from the environment.
|
||||||
|
//<registry endpoint>/:_authToken=${EKKLESIA_PACKAGES_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
### The public package set
|
||||||
|
|
||||||
|
Only packages a module author needs are published. Nothing is published merely because it exists in
|
||||||
|
the workspace.
|
||||||
|
|
||||||
|
| Category | Packages |
|
||||||
|
| --- | --- |
|
||||||
|
| Author API | `@ekklesia/module-sdk`, `@ekklesia/module-database`, `@ekklesia/module-ui-sdk`, `@ekklesia/design-tokens` |
|
||||||
|
| Tooling | `@ekklesia/module-ui-build`, `@ekklesia/module-pack`, `@ekklesia/typescript-config` |
|
||||||
|
| Pre-1.0 component API | `@ekklesia/ui`: publishable, explicitly unstable before 1.0 |
|
||||||
|
| Implementation dependency only | `@ekklesia/contracts`: may be published because the packages above depend on it, but it is **not** a supported direct module-author API |
|
||||||
|
| Never published | `@ekklesia/core`, the applications, `@ekklesia/module-ui-host`, `@ekklesia/module-template`, and every other platform internal |
|
||||||
|
|
||||||
|
### A curated SDK surface
|
||||||
|
|
||||||
|
Before the first external publication, the author-facing SDK is narrowed.
|
||||||
|
|
||||||
|
- **A curated API.** `@ekklesia/module-sdk` exposes what a module author needs, and no longer
|
||||||
|
re-exports the complete `@ekklesia/contracts` barrel.
|
||||||
|
- **No forced Drizzle.** The stable SDK root does not make Drizzle a dependency of modules that do
|
||||||
|
not use it.
|
||||||
|
- **No legacy database contract.** The legacy Drizzle database contract (ADR 0016's facade type) is
|
||||||
|
not part of the standalone author-facing surface. Core still serves the legacy facade for its
|
||||||
|
deprecation window (ADR 0050). This decision only keeps its type out of what new standalone
|
||||||
|
modules build against.
|
||||||
|
- **Drizzle support lives in one place.** Drizzle-specific helpers belong to the
|
||||||
|
`@ekklesia/module-database` Drizzle adapter (ADR 0050).
|
||||||
|
|
||||||
|
### Dependency rules
|
||||||
|
|
||||||
|
These rules apply to the published packages:
|
||||||
|
|
||||||
|
- **React and ReactDOM** are peer dependencies of the public UI packages, plus development
|
||||||
|
dependencies for those packages' own builds and tests. This does not change ADR 0023: a module UI
|
||||||
|
artifact still bundles React. The peer only ensures there is one React in that bundle, the
|
||||||
|
module's own.
|
||||||
|
- **Drizzle** is an optional peer dependency only where the Drizzle adapter needs it.
|
||||||
|
- **Real implementation dependencies,** at run time or build time, remain normal dependencies.
|
||||||
|
|
||||||
|
These rules apply to a module repository:
|
||||||
|
|
||||||
|
- **Runtime and module-code packages** go in `dependencies`: the Author API packages, `@ekklesia/ui`,
|
||||||
|
React, and the module's ORM if it uses one.
|
||||||
|
- **Build tooling and configuration** go in `devDependencies`: `@ekklesia/module-ui-build`,
|
||||||
|
`@ekklesia/module-pack`, `@ekklesia/typescript-config`, and type packages.
|
||||||
|
|
||||||
|
### Versioning
|
||||||
|
|
||||||
|
- **One coordinated version during 0.x.** Every public Ekklesia module-development package uses one
|
||||||
|
release version, equal to the platform release version. The version the platform reports for
|
||||||
|
`moduleSdk` compatibility is therefore the version of the packages a module was built with.
|
||||||
|
- **No mixed trains.** Within a published train, Ekklesia-owned dependencies name the coordinated,
|
||||||
|
tested version, never a version from another train.
|
||||||
|
- **Ranges in source, exact artifacts in lockfiles.** A module may declare compatible ranges in its
|
||||||
|
source `package.json`; its lockfile pins exact artifacts with their integrity hashes.
|
||||||
|
- **`@ekklesia/module-pack` is pinned exactly.** A module repository pins it in its source
|
||||||
|
`package.json`, not only in its lockfile, because its version affects the package it produces.
|
||||||
|
- **Independent package versions are deferred.**
|
||||||
|
|
||||||
|
### Standalone packaging
|
||||||
|
|
||||||
|
- **No monorepo checkout.** A standalone module builds and packages from its own repository and its
|
||||||
|
installed dependencies.
|
||||||
|
- **Its own Node.js range.** The module declares its own Node.js compatibility, preferably in
|
||||||
|
`engines.node`, and the canonical packager reads it from there or from an explicit option. It
|
||||||
|
never requires an enclosing `pnpm-workspace.yaml`.
|
||||||
|
- **Ekklesia packages recognized by identity.** The canonical packager recognizes Ekklesia-owned
|
||||||
|
installed packages by explicit package identity and metadata, never by workspace path. So
|
||||||
|
`incorporates` and the Module SDK compatibility check (ADR 0049) work the same whether a package
|
||||||
|
came from the monorepo or from a registry. Ekklesia packages are never reported as third-party
|
||||||
|
software (ADR 0051).
|
||||||
|
- **No monorepo-root commands at install time.** Publishing workflows for the public packages run
|
||||||
|
their build in the platform's release pipeline. Nothing in a published package runs a
|
||||||
|
monorepo-root command when a consumer installs or packs it.
|
||||||
|
|
||||||
|
### Supply chain
|
||||||
|
|
||||||
|
- **Immutable versions.** A version is never republished with other content, and never withdrawn to
|
||||||
|
be reused.
|
||||||
|
- **Lockfiles.** pnpm lockfile integrity is part of the reproducibility model: modules install with a
|
||||||
|
frozen lockfile.
|
||||||
|
- **Release CI proves the tarballs before publication.** It must:
|
||||||
|
1. build and pack the public package set;
|
||||||
|
2. inspect every tarball: only the intended contents, a `LICENSE`, and no `workspace:` range;
|
||||||
|
3. install those tarballs into a clean standalone module fixture, outside any monorepo;
|
||||||
|
4. prove there that the fixture builds, packages with the canonical packager, and verifies.
|
||||||
|
|
||||||
|
Only then are the versions published.
|
||||||
|
- **Signing and provenance are deferred.**
|
||||||
|
|
||||||
|
### Registry access and the private phase
|
||||||
|
|
||||||
|
- **No secrets in module repositories.** Registry configuration for the scope is normal npm/pnpm
|
||||||
|
configuration.
|
||||||
|
- **Credentials from the environment.** Read and publish credentials come from environment or user
|
||||||
|
configuration: a read credential in module CI where reads need one, and a publish credential only
|
||||||
|
in Ekklesia's release pipeline.
|
||||||
|
- **No permanent provider.**
|
||||||
|
- **Verify access before the first publication.** Check the chosen registry's actual private and
|
||||||
|
read-access behaviour first. Ekklesia's packages must not be published unintentionally to a
|
||||||
|
registry that serves them anonymously while Ekklesia is not public.
|
||||||
|
|
||||||
|
### The release workflow
|
||||||
|
|
||||||
|
During 0.x, public package releases form a **coordinated SDK train** associated with platform
|
||||||
|
releases:
|
||||||
|
|
||||||
|
1. build;
|
||||||
|
2. pack;
|
||||||
|
3. standalone tarball smoke (see [Supply chain](#supply-chain));
|
||||||
|
4. publish immutable versions;
|
||||||
|
5. downstream module CI installs and tests those versions.
|
||||||
|
|
||||||
|
### Signage
|
||||||
|
|
||||||
|
The intended result is a normal standalone Signage repository:
|
||||||
|
- versioned `@ekklesia/*` dependencies;
|
||||||
|
- its own lockfile;
|
||||||
|
- continuous integration that installs, builds, packages, and verifies on its own.
|
||||||
|
|
||||||
|
That CI must not clone Ekklesia or place Signage under `modules/`. The migration itself is a separate
|
||||||
|
change.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A module repository is an ordinary pnpm project. Clone it, install, build, and package it with the
|
||||||
|
canonical packager, with no Ekklesia checkout.
|
||||||
|
- Module authors see a smaller, deliberate API, and the platform can change its internal contracts
|
||||||
|
without breaking them.
|
||||||
|
- One coordinated version keeps compatibility simple: SDK version X is what platform version X
|
||||||
|
reports and accepts. The cost is republishing unchanged packages with each platform release.
|
||||||
|
- A registry move costs each module a configuration change and a lockfile regeneration, but no
|
||||||
|
source change. Package content stays verifiable by integrity hash throughout.
|
||||||
|
- Before the first publication, the SDK surface must be curated and the packager's two monorepo
|
||||||
|
assumptions removed. Both are breaking for code that relied on them.
|
||||||
|
- ADR 0047, still proposed, assembles a module into the platform workspace to test it. Before it is
|
||||||
|
accepted it must instead consume published packages and canonical module packages, consistent with
|
||||||
|
this decision and with ADR 0048.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Git URL, tag, or commit dependencies.** Rejected: they tie modules to one Git host, need a build
|
||||||
|
on every install because `dist` is not committed, and cannot express version ranges.
|
||||||
|
- **Release-asset tarball URLs.** Rejected: each dependency becomes a host-specific URL, and ranges
|
||||||
|
cannot be resolved.
|
||||||
|
- **OCI artifacts for packages.** Rejected: npm and pnpm cannot install from them. (A harness image
|
||||||
|
is a different matter; see ADR 0047.)
|
||||||
|
- **Vendoring Ekklesia packages into each module repository.** Rejected: the copies drift, updates
|
||||||
|
are manual, and there would be no single canonical SDK.
|
||||||
|
- **Requiring host-neutral lockfiles, or an Ekklesia registry proxy that rewrites tarball URLs.** Not
|
||||||
|
required: a one-time lockfile regeneration on a registry move is an acceptable cost, and integrity
|
||||||
|
hashes already keep content verifiable. Ekklesia may still choose a stable endpoint later.
|
||||||
|
- **Publishing `@ekklesia/contracts` as a direct author API.** Rejected: it holds platform-wide
|
||||||
|
contracts. Modules get a curated surface through the SDK packages.
|
||||||
|
- **Externalizing React from module UI bundles to share one runtime.** Rejected again: ADR 0023's
|
||||||
|
reasoning stands, and peers only prevent duplicates within one bundle.
|
||||||
|
- **Independent versions per package now.** Deferred: during 0.x a coordinated train is simpler for
|
||||||
|
module authors and matches how the platform reports its SDK version.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Provisioning a registry, and its credentials.
|
||||||
|
- Public publication on npmjs or any other public registry.
|
||||||
|
- Package signing and provenance.
|
||||||
|
- Independent package versioning.
|
||||||
|
- Dashboard and UI redesign.
|
||||||
|
- The Signage migration itself.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 0054: Ekklesia's module-development packages are publicly readable before launch
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-08
|
||||||
|
- Supersedes (partial): [0053](0053-standalone-module-development-packages.md) — only where it
|
||||||
|
describes the package phase before launch as private: "Gitea may host the private phase", the
|
||||||
|
read credential for module CI in "Registry access and the private phase", and its requirement
|
||||||
|
that packages not be served anonymously while Ekklesia is not public. Every other part of that ADR
|
||||||
|
remains accepted and unchanged: the npm registry protocol, the public package set and its
|
||||||
|
boundaries, the curated SDK, the dependency rules, the coordinated package train, exact release
|
||||||
|
artifacts, standalone module builds, provider portability, supply chain and immutability, and the
|
||||||
|
credential handling for publishing.
|
||||||
|
- Related: [ADR 0049](0049-module-package-v1.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0053 decided how Ekklesia's module-development packages (`@ekklesia/*`) are built, released, and
|
||||||
|
distributed over the npm registry protocol. It assumed that the phase before Ekklesia's public
|
||||||
|
launch would be private: module CI would carry a read credential where reads needed one, and the
|
||||||
|
first publication had to make sure the registry would not serve the packages anonymously.
|
||||||
|
|
||||||
|
Checking the chosen host before the first publication showed what keeping the packages private would
|
||||||
|
take:
|
||||||
|
|
||||||
|
- Gitea 1.27.2 decides package read access by the package **owner's** visibility, never a
|
||||||
|
repository's. The `River_of_Glory_Church` owner is a public organization, so its packages are
|
||||||
|
readable anonymously.
|
||||||
|
- That organization's repositories, Ekklesia's source among them, are already publicly readable.
|
||||||
|
- The instance allows self-registration, so "limited" visibility, readable by any signed-in user,
|
||||||
|
is readable by anyone who creates an account.
|
||||||
|
- Private packages would therefore need a separate private package owner, plus reader accounts and
|
||||||
|
read tokens for every module repository, every module's CI, and every developer.
|
||||||
|
|
||||||
|
The project owner has decided that Ekklesia is not secret. Its source is public; the project is
|
||||||
|
simply not being launched or promoted yet. Hiding the packages built from that public source would
|
||||||
|
protect nothing, and would still cost every module author an account and a token.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. **Ekklesia's module-development packages may be anonymously readable before the project's public
|
||||||
|
launch.** This covers the public package set of ADR 0053: search, metadata, and download.
|
||||||
|
2. **Readability is not a launch, and promises nothing more.** Public readability does not mean:
|
||||||
|
- that Ekklesia has been publicly launched;
|
||||||
|
- that the APIs are stable beyond the project's pre-1.0 SemVer policy;
|
||||||
|
- that third-party production use is supported;
|
||||||
|
- that a marketplace or public module ecosystem exists.
|
||||||
|
3. **Reading needs no authentication in this phase.** Installing `@ekklesia/*` needs no account,
|
||||||
|
reader account, read token, or authenticated install workflow. A module repository configures only
|
||||||
|
the scope's registry.
|
||||||
|
4. **Publication stays authenticated and tightly controlled.** Only Ekklesia's release process
|
||||||
|
publishes, with one dedicated publisher credential that holds nothing beyond package write
|
||||||
|
access, used only by that process, as ADR 0053's credential handling for publishing already
|
||||||
|
requires.
|
||||||
|
5. **Published versions stay immutable by Ekklesia policy.** A published version is never deleted or
|
||||||
|
republished with other bytes, whatever the registry technically allows.
|
||||||
|
6. **The current Gitea owner is an acceptable host.** The `River_of_Glory_Church` package namespace
|
||||||
|
on the current Gitea may host this phase, as long as publishing permission stays restricted to
|
||||||
|
the publisher. No private package organization is needed.
|
||||||
|
7. **A public launch is a separate decision.** Launching or promoting Ekklesia publicly, and anything
|
||||||
|
that changes for its packages then, is a product and release decision this ADR does not make.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Module authors and module CI need no Ekklesia credential.** The committed `.npmrc` scope mapping
|
||||||
|
is the whole registry configuration. ADR 0053's example of a read credential in user or CI
|
||||||
|
configuration is no longer needed in this phase.
|
||||||
|
- **ADR 0053's pre-publication access check changes meaning.** It now confirms that anonymous reads
|
||||||
|
work as intended and that publishing is refused without the publisher's permission, rather than
|
||||||
|
that reads are private. The disposable-registry suite proves both, and the publication tool can
|
||||||
|
require an anonymous download to match each published artifact.
|
||||||
|
- **Restricting publishing is now the whole access boundary**, so the publisher credential's scope,
|
||||||
|
where it is held, and who can reach it matter all the more.
|
||||||
|
- **Making the packages private again would be a new decision.** Package names, versions, and
|
||||||
|
contents already published stay public, because a published version is never deleted.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **A private package organization on the same Gitea.** Rejected: it hides nothing that is not
|
||||||
|
already public in the source, and it requires reader accounts and tokens for every module
|
||||||
|
repository, its CI, and every developer.
|
||||||
|
- **"Limited" package visibility.** Rejected: with self-registration open, anyone can read anyway,
|
||||||
|
while the configuration suggests otherwise.
|
||||||
|
- **Requiring sign-in for the whole instance.** Rejected: it would also hide the public
|
||||||
|
repositories.
|
||||||
|
- **Another registry for a private phase.** Unnecessary once reads are public.
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# 0055: Native redistributable packages carry corresponding-source artifacts
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-09
|
||||||
|
- Extends: [0049](0049-module-package-v1.md). ADR 0049 withholds distributable-release status from a
|
||||||
|
package until its licence handling is proven. This ADR adds what proving it means for a native
|
||||||
|
package that redistributes copyleft code: a sibling corresponding-source artifact, and a
|
||||||
|
fail-closed gate for public distribution. Module Package v1 is unchanged, and `packageFormat`
|
||||||
|
stays **1**.
|
||||||
|
- Decides: the item [ADR 0052](0052-reviewed-license-supplements.md) deferred, "the legal and
|
||||||
|
compliance determination of source obligations for each redistributed binary, and how a
|
||||||
|
determination is recorded", as a project policy. Reviewed supplements are unchanged in purpose
|
||||||
|
and format.
|
||||||
|
- Related: [ADR 0051](0051-module-package-license-material.md), whose rules for a module's own
|
||||||
|
licence are unchanged.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A native Module Package (ADR 0049) copies native dependencies into its `node_modules` exactly as
|
||||||
|
built. The first one, the packager's native proof with sharp, redistributes
|
||||||
|
`@img/sharp-libvips-linux-x64`. That is one shared object with about 29 libraries built into it.
|
||||||
|
Several are under the GNU LGPL, and cairo and some Rust crates are under the MPL.
|
||||||
|
|
||||||
|
ADR 0052 made the licence and notice side work. A reviewed supplement adds the licence, copyright,
|
||||||
|
and notice files the package omits, with the exact upstream source each came from. But ADR 0052 was
|
||||||
|
explicit that carrying material is not meeting obligations. The LGPL and the MPL also oblige whoever
|
||||||
|
distributes the binary to make the corresponding source available. For a library linked into a
|
||||||
|
larger work, that includes what a recipient needs to rebuild the library and use a modified version.
|
||||||
|
Recording upstream URLs does not do that: URLs move, tags are rewritten, and a mirror is somebody
|
||||||
|
else's promise.
|
||||||
|
|
||||||
|
Until this was decided, every native package containing such components was blocked from public
|
||||||
|
distribution, and the blocker had no defined way to be lifted.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Native packages may contain copyleft native dependencies
|
||||||
|
|
||||||
|
A native Module Package may redistribute native dependencies under the LGPL, the MPL, or a similar
|
||||||
|
licence with source obligations. It may be **distributed publicly** only when it is released
|
||||||
|
together with a **corresponding-source artifact** that meets this ADR. A package whose artifact
|
||||||
|
cannot be produced and verified is not approved for public distribution.
|
||||||
|
|
||||||
|
This is Ekklesia's **project compliance policy**: a conservative rule the project holds itself to.
|
||||||
|
It is not legal advice, and it is not legal certification. Repository review of the artifact, its
|
||||||
|
inputs, and its instructions is project and code review. It does not replace professional legal
|
||||||
|
review, and nothing built from this ADR may claim that it does.
|
||||||
|
|
||||||
|
### Supplements and source artifacts do different jobs
|
||||||
|
|
||||||
|
- **A reviewed supplement (ADR 0052)** identifies and verifies the licence and notice material for a
|
||||||
|
redistributed binary. It travels inside the package, in `THIRD_PARTY_NOTICES`.
|
||||||
|
- **A corresponding-source artifact** carries the source, patches, build definitions, and rebuild
|
||||||
|
and relink instructions for the covered native components. It travels beside the package, as a
|
||||||
|
separate release artifact.
|
||||||
|
|
||||||
|
A supplement alone never satisfies the source-availability policy. A source artifact never replaces
|
||||||
|
the supplement or `THIRD_PARTY_NOTICES`.
|
||||||
|
|
||||||
|
### What the artifact contains
|
||||||
|
|
||||||
|
For each covered native dependency the package redistributes, the artifact contains:
|
||||||
|
|
||||||
|
- the exact source the binary was built from, as bytes, not as references:
|
||||||
|
- its build definitions;
|
||||||
|
- the source archive of every library compiled into it;
|
||||||
|
- every patch the build applied;
|
||||||
|
- every source package a language package manager resolved for the build;
|
||||||
|
- the source of any runtime library the toolchain linked into it;
|
||||||
|
- the licence and copyright material: the reviewed supplement, and the files inside the sources;
|
||||||
|
- **instructions** that are technically actionable. They name what the artifact corresponds to and
|
||||||
|
its target, and say how to apply the patches and rebuild the covered library. They also say how a
|
||||||
|
rebuilt or modified library replaces the redistributed one in the installed module, and which
|
||||||
|
toolchain and configuration to use, or point to the included build definitions;
|
||||||
|
- a deterministic, machine-readable manifest of every component and file, each with its digest,
|
||||||
|
plus the human-readable provenance.
|
||||||
|
|
||||||
|
Components that are not part of the binary are left out, each with its reason recorded: build-only,
|
||||||
|
test-only, or another platform's. The exception is a build-time input needed to rebuild the covered
|
||||||
|
library, such as a source package a build script requires. That is included and marked as
|
||||||
|
build-only. Host system libraries and general-purpose build tools are not redistributed, and the
|
||||||
|
instructions name the versions they need.
|
||||||
|
|
||||||
|
### It is tied to exact inputs, and verified before it is written
|
||||||
|
|
||||||
|
- **Bound to the binary.** An artifact names each covered dependency by package name, version, and
|
||||||
|
the content digest a supplement is matched by (ADR 0052). It names the module identity and target
|
||||||
|
of the package it accompanies. An artifact for one version of a binary never satisfies another.
|
||||||
|
- **From reviewed inputs only.** The inputs come from a checked-in, reviewed **source recipe** per
|
||||||
|
covered dependency version. The recipe records each component's name, version, origin,
|
||||||
|
relationship to the binary, and licence material, and each source file's and patch's size and
|
||||||
|
SHA-256.
|
||||||
|
- **Acquisition is separate from construction.** Obtaining the recorded bytes may use the network,
|
||||||
|
in an explicit preparation step that stores them in a local cache keyed by content digest.
|
||||||
|
Constructing the artifact is offline. It reads only the cache and checked-in files, verifies every
|
||||||
|
byte against the recipe before writing anything, and never fetches or trusts a current upstream
|
||||||
|
response.
|
||||||
|
- **Deterministic.** The same inputs produce the same artifact, byte for byte, in any working
|
||||||
|
directory: normalized relative paths, fixed ownership, modes, and timestamps, sorted entries, and
|
||||||
|
no machine-specific path.
|
||||||
|
|
||||||
|
### Binary and source are linked by recorded digests
|
||||||
|
|
||||||
|
A public release of a native package with covered components consists of sibling artifacts:
|
||||||
|
|
||||||
|
- the Module Package archive and its SHA-256 (ADR 0049);
|
||||||
|
- the corresponding-source archive and its SHA-256;
|
||||||
|
- machine-readable **redistribution metadata** recording:
|
||||||
|
- the package archive's name and SHA-256;
|
||||||
|
- the module identity and target;
|
||||||
|
- each covered dependency's name, version, licence, and content digest;
|
||||||
|
- the supplement and recipe it was assembled from;
|
||||||
|
- the source archive's name and SHA-256, and the SHA-256 of its manifest;
|
||||||
|
- a **release notice**, displayed alongside the binary wherever it is offered. It says, in
|
||||||
|
substance, that the release includes third-party software under licences including the GNU LGPL
|
||||||
|
or the MPL, and that corresponding source and build and relink materials for the covered native
|
||||||
|
dependencies are in the accompanying artifact, named by its exact file name and SHA-256.
|
||||||
|
|
||||||
|
A reviewer can therefore tell exactly which source artifact corresponds to which redistributed
|
||||||
|
binary, from digests, without guessing from file names. The source archive is never placed inside
|
||||||
|
the Module Package. `THIRD_PARTY_NOTICES` stays as it is, and never duplicates the source.
|
||||||
|
|
||||||
|
### Public distribution fails closed
|
||||||
|
|
||||||
|
Building and testing packages stays unrestricted: the packager produces native packages exactly as
|
||||||
|
before, for development, CI, and private use. **Public redistribution approval** is a separate gate.
|
||||||
|
For a package intended for public distribution, the gate requires that:
|
||||||
|
|
||||||
|
- the package's native dependency inventory is known;
|
||||||
|
- every covered dependency has its reviewed supplement and source recipe;
|
||||||
|
- the corresponding-source artifact is present;
|
||||||
|
- every digest matches;
|
||||||
|
- the artifact's manifest validates against the recipe and the archive's own contents;
|
||||||
|
- the release notice names the exact artifact.
|
||||||
|
|
||||||
|
If any requirement fails, approval fails. Bundled (not copied) code under such a licence is not
|
||||||
|
handled by this mechanism, so the gate refuses it rather than approving it.
|
||||||
|
|
||||||
|
### Retention, and no rewriting history
|
||||||
|
|
||||||
|
- Ekklesia keeps a release's corresponding-source artifact for as long as it distributes, or
|
||||||
|
supports access to, the matching binary release. Nothing deletes one automatically while that
|
||||||
|
binary remains available.
|
||||||
|
- The bytes of a source artifact for an existing binary release are never replaced under the same
|
||||||
|
identity.
|
||||||
|
- A correction is a new release, or an explicitly versioned compliance artifact that the
|
||||||
|
redistribution metadata names. History is never silently replaced.
|
||||||
|
|
||||||
|
### What this ADR does not do
|
||||||
|
|
||||||
|
- It does not change any module's own licence, or ADR 0051's rules for it. The module's own source
|
||||||
|
obligations are met by the module's own repository, as before.
|
||||||
|
- It does not make **runtime downloading** an acceptable workaround. Fetching a copyleft library at
|
||||||
|
run time instead of redistributing it moves the obligation rather than meeting it, makes
|
||||||
|
installations depend on a third party, and breaks ADR 0048's self-contained installed modules. It
|
||||||
|
is not the preferred path.
|
||||||
|
- It does not change Module Package v1, the verifier's acceptance of a package, or the content of
|
||||||
|
existing supplements.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Native modules that need copyleft native libraries, starting with sharp's libvips, can be
|
||||||
|
distributed publicly under a defined, checkable policy, instead of being blocked indefinitely.
|
||||||
|
- Each covered dependency version needs a reviewed source recipe, as it needs a reviewed supplement.
|
||||||
|
A dependency bump needs both before public distribution works again.
|
||||||
|
- Releases carry an artifact many times larger than the package itself (about 200 MB for libvips
|
||||||
|
1.3.4, against a 9 MB package), and Ekklesia commits to retaining it.
|
||||||
|
- Constructing the artifact needs the verified source cache. Acquisition happens once per recipe,
|
||||||
|
not on every build.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Treat supplements, source URLs, or a written offer as sufficient.** Rejected: URLs and offers
|
||||||
|
depend on third parties and on time. This policy requires the source itself, retained with the
|
||||||
|
release.
|
||||||
|
- **Put the source inside the Module Package.** Rejected: installations would store hundreds of
|
||||||
|
megabytes they never need, and the package format would change for a distribution concern.
|
||||||
|
- **Download sources while constructing the artifact.** Rejected: a release would depend on mutable
|
||||||
|
remote responses. Acquisition is explicit and verified, and construction is offline.
|
||||||
|
- **Download native libraries at run time.** Rejected, as above.
|
||||||
|
- **Per-dependency special cases in code.** Rejected for the same reason as in ADR 0052: recipes are
|
||||||
|
reviewed data, and the mechanism knows no particular library.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
- Hosting and serving source artifacts for published module releases, and the release CI that runs
|
||||||
|
the gate and keeps the verified source cache.
|
||||||
|
- Corresponding source for bundled copyleft code, if a module ever needs it.
|
||||||
|
- Recipes provided by module repositories, alongside the question ADR 0052 deferred for supplements.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 0056: A device credential lasts while the device is in use
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-10-12
|
||||||
|
- Supersedes: the "Session lifetime" section of [0036](0036-managed-device-identity-and-pairing.md).
|
||||||
|
Everything else in ADR 0036 — the device as its own principal, pairing, revocation on every
|
||||||
|
request, re-pairing as a new identity — is unchanged.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0036 gave a device credential a fixed 180 days from pairing. The intent was that a screen on a
|
||||||
|
ceiling should not need re-pairing on a short cycle, and that an abandoned screen should eventually
|
||||||
|
stop being an identity.
|
||||||
|
|
||||||
|
A fixed horizon does the second and fails the first. Every screen in the building expires on the
|
||||||
|
anniversary of its pairing whether or not it was in use the day before, and drops to a pairing code
|
||||||
|
in front of the congregation until someone with Dashboard access walks to it. For screens paired
|
||||||
|
together, that is every screen at once. Nothing about a working, unrevoked screen is less trustworthy
|
||||||
|
on day 181 than on day 179; only the calendar changed.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The 180 days are an idle window, not a lifetime.** A device credential is accepted while the
|
||||||
|
device was last seen less than 180 days ago (or, if it has never been seen, was paired less than 180
|
||||||
|
days ago). `lastSeenAt` already moves forward on authenticated device requests (throttled to one
|
||||||
|
write per 15 minutes), so the server-side window slides with use and needs no new column or
|
||||||
|
migration.
|
||||||
|
|
||||||
|
**The browser's copy is renewed by the host, not rotated.** The display descriptor —
|
||||||
|
`GET /api/v1/module-displays/<module>`, which every display requests when it starts, when it
|
||||||
|
reconnects, and periodically while it runs — re-issues the device cookie that authenticated the
|
||||||
|
request with a fresh 180-day `Max-Age` and the same attributes as at pairing (`HttpOnly`,
|
||||||
|
`SameSite=Strict`, host-only, `Path=/`, `Secure` on https). The value is unchanged.
|
||||||
|
|
||||||
|
Renewal happens only for a request that already resolved to a live, unrevoked device. A revoked,
|
||||||
|
expired, or unknown credential is anonymous, as before, and is never renewed; a person viewing a
|
||||||
|
display never receives a device cookie.
|
||||||
|
|
||||||
|
**Revocation remains the boundary.** It is still checked on every request, and an administrator's
|
||||||
|
revoke still takes effect immediately.
|
||||||
|
|
||||||
|
## Why not rotate
|
||||||
|
|
||||||
|
Rotation (issue a new value, invalidate the old) would bound how long any one stolen value works and
|
||||||
|
could reveal a cloned credential. For an unattended screen it trades that for a new way to strand
|
||||||
|
the display: a renewal response lost to a network drop, a power cut, or a browser that never
|
||||||
|
persisted the new cookie leaves the screen holding a dead credential and showing a pairing code,
|
||||||
|
which is the failure this decision exists to remove. A refresh-token scheme with overlap windows
|
||||||
|
could avoid that, at the cost of real complexity in the one place mistakes are invisible until a
|
||||||
|
wall goes dark. That is not justified while revocation is immediate and a device principal can only
|
||||||
|
read the displays it was paired for.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A screen that is used at least once every 180 days stays paired until an administrator revokes
|
||||||
|
it. An abandoned screen still stops being an identity 180 days after it was last seen.
|
||||||
|
- Existing credentials need no migration. A screen paired under the fixed rule is renewed the next
|
||||||
|
time it requests its descriptor, provided its browser still holds the cookie.
|
||||||
|
- A stolen device credential stays usable for as long as anyone keeps using it, until it is revoked.
|
||||||
|
Under ADR 0036 it was usable for at most 180 days from pairing. The exposure is bounded by what a
|
||||||
|
device may do — the display permissions of the module it was paired for, never a person's access
|
||||||
|
— and by revocation. Operators can see each device's last-seen time and revoke anything
|
||||||
|
unexpected.
|
||||||
|
- A credential is not bound to a browser or network. Like a human session cookie, whoever presents
|
||||||
|
it is the device.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- **Keep the fixed 180 days.** Rejected: every in-use screen fails on a schedule, and screens paired
|
||||||
|
together fail together.
|
||||||
|
- **Never expire.** Rejected: an abandoned or forgotten screen would remain an identity forever, and
|
||||||
|
its credential would never stop working without someone remembering to revoke it.
|
||||||
|
- **Rotate on renewal.** Rejected for now; see above.
|
||||||
|
- **Renew on every authenticated response.** Rejected: principal resolution happens deep in shared
|
||||||
|
middleware that never writes responses. One endpoint every display already requests periodically
|
||||||
|
is enough, and keeps the behaviour in one visible place.
|
||||||
@@ -44,7 +44,7 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
|
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted
|
||||||
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
|
- [0034: Dashboard browser authentication is host-owned and principal-first](0034-host-owned-browser-authentication.md) — Accepted
|
||||||
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
|
||||||
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
|
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted (session lifetime superseded by 0056)
|
||||||
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
|
||||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||||
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
- [0039: Shared design tokens and foundational elements](0039-shared-design-tokens-and-elements.md) — Accepted
|
||||||
@@ -59,4 +59,9 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted
|
- [0048: Ekklesia ships one generic image, and installed modules live outside it](0048-generic-image-and-installed-module-storage.md) — Accepted
|
||||||
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
|
- [0049: A module release is one immutable, self-contained package: Ekklesia Module Package v1](0049-module-package-v1.md) — Accepted
|
||||||
- [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted
|
- [0050: Module Database ABI v1: modules and Core exchange SQL, never ORM objects](0050-module-database-abi-v1.md) — Accepted
|
||||||
- [0051: Module packages declare their own license and carry its text](0051-module-package-license-material.md) — Proposed
|
- [0051: Module packages declare their own license and carry its text](0051-module-package-license-material.md) — Accepted
|
||||||
|
- [0052: Reviewed license supplements cover redistributed dependencies whose packages omit required material](0052-reviewed-license-supplements.md) — Accepted
|
||||||
|
- [0053: A module builds outside the monorepo, from Ekklesia packages published over the npm registry protocol](0053-standalone-module-development-packages.md) — Accepted
|
||||||
|
- [0054: Ekklesia's module-development packages are publicly readable before launch](0054-public-package-reads-before-launch.md) — Accepted
|
||||||
|
- [0055: Native redistributable packages carry corresponding-source artifacts](0055-corresponding-source-artifacts.md) — Accepted
|
||||||
|
- [0056: A device credential lasts while the device is in use](0056-device-credentials-last-while-in-use.md) — Accepted
|
||||||
|
|||||||
@@ -679,12 +679,38 @@ Module Package v1, a deterministic `.tar.gz` with its SHA-256.
|
|||||||
- **The packager.** `@ekklesia/module-pack` bundles the module's backend with every pure-JavaScript
|
- **The packager.** `@ekklesia/module-pack` bundles the module's backend with every pure-JavaScript
|
||||||
dependency, the SDK, contracts, and Drizzle included. It copies the built browser artifacts and
|
dependency, the SDK, contracts, and Drizzle included. It copies the built browser artifacts and
|
||||||
migrations, generates the runtime `package.json`, `ekklesia.package.json` with every file's digest,
|
migrations, generates the runtime `package.json`, `ekklesia.package.json` with every file's digest,
|
||||||
and `THIRD_PARTY_NOTICES`, and writes the archive.
|
copies the explicitly supplied module `LICENSE` bytes, generates `THIRD_PARTY_NOTICES` for bundled
|
||||||
|
third parties, and writes the archive. The source module's string SPDX expression is required and
|
||||||
|
becomes the package metadata's authoritative `license`; the packager neither infers nor downloads
|
||||||
|
license material.
|
||||||
|
- **Native target (`linux-x64-glibc`).** Native or platform-specific dependencies stay outside
|
||||||
|
the bundle. Each one is copied with exactly its production runtime closure, as real files flat
|
||||||
|
under the package's `node_modules/`, and only the platform packages that match the target are
|
||||||
|
included. The package's `runtime` records the Node-API version and minimum glibc version, both
|
||||||
|
read from the native binaries themselves.
|
||||||
|
- **License supplements.** Where a redistributed dependency omits required licence material,
|
||||||
|
reviewed supplements checked in with the packager add it
|
||||||
|
([ADR 0052](decisions/0052-reviewed-license-supplements.md)). Each supplement matches one exact
|
||||||
|
package name, version, and content digest; anything else fails closed.
|
||||||
|
- **Corresponding source.** A native package that redistributes code under the LGPL, the MPL, or
|
||||||
|
a similar licence is approved for public distribution only together with a sibling
|
||||||
|
corresponding-source artifact. The artifact is built offline from a reviewed source recipe and a
|
||||||
|
verified source cache, and linked to the package by digest
|
||||||
|
([ADR 0055](decisions/0055-corresponding-source-artifacts.md)). Building and testing packages is
|
||||||
|
unaffected.
|
||||||
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
|
- **The verifier.** Core's `verifyModulePackage` reads the archive in memory and refuses links,
|
||||||
special files, and escaping paths. It checks every digest and identity, the target and runtime,
|
special files, and escaping paths. It checks every digest and identity, the target and runtime,
|
||||||
runs the platform's own module, schema, and migration validation, and loads the backend once in a
|
runs the platform's own module, schema, and migration validation, and loads the backend once in a
|
||||||
short-lived process with an empty environment. Only then does it extract anything.
|
short-lived process with an empty environment. Only then does it copy the package to a requested
|
||||||
- **What exists today.** Only the `any` target. A manifest-v8 ABI module carries no
|
output directory. In the hardened container invocation, `/tmp` remains `noexec`; the temporary
|
||||||
|
candidate lives on a dedicated, bounded executable tmpfs because a native addon's code must be
|
||||||
|
mapped executable to load.
|
||||||
|
- **A native package's host checks.** It is accepted only on a host whose operating system, CPU,
|
||||||
|
C library family and version, and Node-API version Node.js itself reports as compatible.
|
||||||
|
- **ELF files.** Every ELF file in it must be built for the target's machine.
|
||||||
|
- **What an `any` package may not hold:** `node_modules`, addons, and ELF files.
|
||||||
|
- **What exists today.** The `any` and `linux-x64-glibc` targets. No other operating system or CPU
|
||||||
|
target exists yet. A manifest-v8 ABI module carries no
|
||||||
`transitional.databaseRuntime`; Drizzle may be bundled as its private implementation detail, and
|
`transitional.databaseRuntime`; Drizzle may be bundled as its private implementation detail, and
|
||||||
Core validates plain schema metadata without comparing ORM versions. A version 8 module that
|
Core validates plain schema metadata without comparing ORM versions. A version 8 module that
|
||||||
omits `database` remains on the temporary legacy facade and keeps ADR 0049's exact transitional
|
omits `database` remains on the temporary legacy facade and keeps ADR 0049's exact transitional
|
||||||
|
|||||||
@@ -139,10 +139,12 @@ These rules map platform ownership to filesystem and package boundaries. The exe
|
|||||||
stay beside it, but ordinarily never replaces it: a workspace package is linked into its consumers
|
stay beside it, but ordinarily never replaces it: a workspace package is linked into its consumers
|
||||||
as a directory, so its imports resolve from its own `node_modules`, where a consumer never
|
as a directory, so its imports resolve from its own `node_modules`, where a consumer never
|
||||||
satisfies a peer, and a production install (`pnpm install --prod`, as the production image runs)
|
satisfies a peer, and a production install (`pnpm install --prod`, as the production image runs)
|
||||||
omits development dependencies. The one narrow exception is
|
omits development dependencies. There are two narrow exceptions, each for one package and one
|
||||||
`@ekklesia/module-database`'s `drizzle-orm` peer: modules choose that compatible runtime and the
|
peer. `@ekklesia/module-database`'s `drizzle-orm` peer: modules choose that compatible runtime and
|
||||||
Module Package v1 bundler incorporates it into their self-contained backend; Core never loads the
|
the Module Package v1 bundler incorporates it into their self-contained backend; Core never loads
|
||||||
adapter package. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing `react`,
|
the adapter package. And `@ekklesia/ui`'s `react` peer (ADR 0053): the shared elements are only
|
||||||
|
ever bundled into browser artifacts, and the consumer's own React must be the one in the bundle;
|
||||||
|
no production Node.js process loads them. Type-only imports are exempt; a `.tsx`/`.jsx` file counts as importing `react`,
|
||||||
because the automatic JSX runtime imports `react/jsx-runtime` implicitly
|
because the automatic JSX runtime imports `react/jsx-runtime` implicitly
|
||||||
(`runtime-imports-are-production-dependencies`).
|
(`runtime-imports-are-production-dependencies`).
|
||||||
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
|
- Workspace aliases are forbidden because they obscure package ownership in the dependency graph.
|
||||||
|
|||||||
@@ -1403,7 +1403,8 @@ stays declarative and database-free. See
|
|||||||
contract; this section covers only how `apps/api` composes and wires it.
|
contract; this section covers only how `apps/api` composes and wires it.
|
||||||
|
|
||||||
- **`ModuleDatabase`.** A restricted, typed view over `NodePgDatabase`
|
- **`ModuleDatabase`.** A restricted, typed view over `NodePgDatabase`
|
||||||
(`packages/contracts/src/module-database.ts`), exposing exactly `select`, `selectDistinct`,
|
(`core/src/modules/database.ts`, Core's own type: no published package references Drizzle, per
|
||||||
|
ADR 0053), exposing exactly `select`, `selectDistinct`,
|
||||||
`selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`, each preserving
|
`selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`, each preserving
|
||||||
Drizzle's own table-aware argument and result inference. It exposes no `$client`, no internal
|
Drizzle's own table-aware argument and result inference. It exposes no `$client`, no internal
|
||||||
session/dialect state, no schema-bound relational `query` builder, and no connection string,
|
session/dialect state, no schema-bound relational `query` builder, and no connection string,
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
# Releases and the public package train
|
||||||
|
|
||||||
|
An Ekklesia platform release is one version. The platform image, the private workspace packages,
|
||||||
|
and the public `@ekklesia/*` module-development packages all carry it. This page covers the order a
|
||||||
|
release happens in and the public package train
|
||||||
|
([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md)): how its
|
||||||
|
artifacts are produced, what proves them, and how exactly those artifacts are published.
|
||||||
|
|
||||||
|
> **The packages are publicly readable, on purpose**
|
||||||
|
> ([ADR 0054](../architecture/decisions/0054-public-package-reads-before-launch.md)). Anyone can
|
||||||
|
> search, read, and install `@ekklesia/*` from the registry with no account and no token, like any
|
||||||
|
> public npm package. That is not a public launch: Ekklesia's source is already publicly readable,
|
||||||
|
> and the project is simply not promoted yet. Only **publishing** is restricted, to one manual,
|
||||||
|
> authenticated workflow. Before 1.0, the APIs may change between versions under the project's
|
||||||
|
> SemVer policy: a 0.x minor version may break them.
|
||||||
|
|
||||||
|
## Where each step sits
|
||||||
|
|
||||||
|
```text
|
||||||
|
version set ─▶ build ─▶ test ─▶ pack the public package train ─▶ standalone consumer proof
|
||||||
|
│
|
||||||
|
tag and release (maintainer) ◀─ publish the exact train (manual) ◀───┘
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Version set.** A `chore(release): prepare X` pull request sets the release version: the root
|
||||||
|
`package.json`, every platform workspace package (the module template versions as a module, not
|
||||||
|
with the platform), the image build example in `deploy/README.md`, and the standalone fixture's
|
||||||
|
exact `@ekklesia/module-pack` pin and `@ekklesia/*` ranges
|
||||||
|
(`tests/fixtures/standalone-module/package.json`). The tag must point at a commit that carries
|
||||||
|
the version it names.
|
||||||
|
|
||||||
|
A minor version (0.x → 0.(x+1)) is a breaking release before 1.0, so manifests that declare
|
||||||
|
`compatibility.platform` and `compatibility.moduleSdk` as `^0.x.0` no longer match it. In the
|
||||||
|
same pull request, move to the new range every module built for this release (the template, the
|
||||||
|
standalone fixture, the native proof module), the test fixtures that model a module of the
|
||||||
|
current release or the current platform, and the module-development guide's examples. A patch
|
||||||
|
version changes none of these.
|
||||||
|
2. **Build and test.** `pnpm verify`, the integration suite, and the deploy smoke, as for any change.
|
||||||
|
3. **Pack the public package train.** `pnpm packages:release` (below).
|
||||||
|
4. **Standalone consumer proof.** A module outside the repository consumes exactly those
|
||||||
|
artifacts (below).
|
||||||
|
5. **Publish the exact train.** By hand, with the `Publish packages` workflow on the version-set
|
||||||
|
commit on `main` ([Publishing the train](#publishing-the-train)). It publishes the artifacts its
|
||||||
|
own run's gate (steps 3 and 4) proved, and nothing else.
|
||||||
|
6. **Tag and release.** Unchanged: the maintainer tags the merge commit of the version-set pull
|
||||||
|
request. Nothing here creates, moves, or requires a tag.
|
||||||
|
|
||||||
|
Steps 2 to 4 run in CI on every pull request and on `main`, and CI also publishes each proven train
|
||||||
|
to a disposable registry (`package-publication`), never the real one. Step 5 runs only when a
|
||||||
|
maintainer starts it.
|
||||||
|
|
||||||
|
## The public package train
|
||||||
|
|
||||||
|
| Package | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| `@ekklesia/module-sdk`, `@ekklesia/module-database`, `@ekklesia/module-ui-sdk`, `@ekklesia/design-tokens` | author API |
|
||||||
|
| `@ekklesia/module-ui-build`, `@ekklesia/module-pack`, `@ekklesia/typescript-config` | tooling |
|
||||||
|
| `@ekklesia/ui` | publishable, pre-1.0 component API |
|
||||||
|
| `@ekklesia/contracts` | implementation dependency of the packages above: published only because they need it, never a supported direct dependency of a module |
|
||||||
|
|
||||||
|
The list lives in `scripts/package-release.ts` (`PUBLIC_PACKAGES`). Every other workspace package is
|
||||||
|
`private`, and the release builder refuses to run if a package outside the list is publishable or
|
||||||
|
carries the `module-development` distribution marker.
|
||||||
|
|
||||||
|
- **One version.** Every package in the train has the platform release version.
|
||||||
|
- **One train.** Every `@ekklesia/*` dependency in a packed manifest names a package of the same
|
||||||
|
train at exactly that version. `workspace:` ranges become those exact versions when packed.
|
||||||
|
- **No independent versions.** A package cannot be released on its own.
|
||||||
|
|
||||||
|
## Producing the release artifacts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm packages:release # writes .package-release/ekklesia-packages-<version>/
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/package-release.ts`:
|
||||||
|
|
||||||
|
1. **Refuses a modified source tree, before it builds anything.** A release is built from a
|
||||||
|
commit. A tree is clean when `git status` shows no change to a tracked file and no untracked file
|
||||||
|
that Git does not ignore. Ignored files are build output and package-manager state:
|
||||||
|
`node_modules`, `dist`, `.turbo`, and pnpm's `.pnpm-store`, which pnpm creates at the checkout's
|
||||||
|
root when the checkout is its own mount (a CI runner's workspace volume). The failure lists every
|
||||||
|
change. `--allow-modified` exists for local development only; its manifest then says
|
||||||
|
`"modified": true`, and such a release must never be published.
|
||||||
|
2. **Checks the release set and versions** in the workspace.
|
||||||
|
3. **Rebuilds from clean output.** It removes each public package's `dist/` and rebuilds without the
|
||||||
|
build cache, so output from a deleted source file can never ship. It then checks the source tree
|
||||||
|
again: a build that changed a tracked file, or wrote an untracked one Git does not ignore, fails
|
||||||
|
the release, because what is packed must be exactly the commit.
|
||||||
|
4. **Packs each package with `pnpm pack`.** Pack-only pnpm hooks (`scripts/lib/pack-hooks.cjs`)
|
||||||
|
apply, never the workspace's install settings ([Reproducibility](#reproducibility)).
|
||||||
|
5. **Inspects every tarball.** It checks:
|
||||||
|
- the expected name, version, and file name;
|
||||||
|
- a `LICENSE`, and only the `files` the manifest declares, each built file with the source it
|
||||||
|
came from, and no source, test, or configuration file;
|
||||||
|
- no `workspace:` range, no `devDependencies`, no `publishConfig`, and no install-time lifecycle
|
||||||
|
script;
|
||||||
|
- only public `@ekklesia/*` dependencies, at the train's version;
|
||||||
|
- the distribution marker;
|
||||||
|
- no local path (the checkout, the home directory, or the temporary directory) in any file.
|
||||||
|
6. **Checks the set.** The release must hold exactly the expected tarballs: none missing, none extra.
|
||||||
|
7. **Writes the release directory only when everything passed,** in one step. It refuses to
|
||||||
|
overwrite one that exists.
|
||||||
|
|
||||||
|
```text
|
||||||
|
ekklesia-packages-<version>/
|
||||||
|
├── ekklesia-contracts-<version>.tgz
|
||||||
|
├── ekklesia-design-tokens-<version>.tgz
|
||||||
|
├── … one .tgz per package in the train
|
||||||
|
├── SHA256SUMS `sha256sum -c SHA256SUMS` compatible
|
||||||
|
└── ekklesia-package-train.json the release manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
### The release manifest
|
||||||
|
|
||||||
|
`ekklesia-package-train.json` describes the train by content. It is provider-neutral: it names no
|
||||||
|
registry and is not a registry catalog.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"packageTrain": 1,
|
||||||
|
"version": "0.2.0",
|
||||||
|
"source": { "modified": false, "revision": "<full commit id>" },
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"file": "ekklesia-contracts-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-<base64>",
|
||||||
|
"name": "@ekklesia/contracts",
|
||||||
|
"role": "implementation",
|
||||||
|
"sha256": "<hex>",
|
||||||
|
"size": 87379,
|
||||||
|
"version": "0.2.0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`integrity` is the npm `dist.integrity` form: a registry computes the same value from the same
|
||||||
|
bytes, and a module's lockfile records it. The packages are sorted by name, and the file's bytes
|
||||||
|
depend only on the commit.
|
||||||
|
|
||||||
|
## Proving the artifacts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test:package-release # scripts/test-package-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Builds the train** from this checkout. Nothing is built before it.
|
||||||
|
2. **Builds it again** from a second worktree of the same commit at another absolute path, and
|
||||||
|
requires both release directories to be byte-identical: every tarball, `SHA256SUMS`, and the
|
||||||
|
manifest.
|
||||||
|
3. **Builds Core's verifier**, then **runs the standalone consumer proof** (`scripts/test-standalone-module.sh --release <dir>`) on
|
||||||
|
that release:
|
||||||
|
- **The release is checked first.** Every artifact must match the manifest's size, SHA-256, and
|
||||||
|
integrity, and the release must not be built from a modified tree.
|
||||||
|
- **The fixture module installs from those tarballs alone.** It is
|
||||||
|
`tests/fixtures/standalone-module`, copied outside the repository. Every `@ekklesia/*`
|
||||||
|
package, direct or transitive, resolves to its release tarball. There is no workspace link and
|
||||||
|
no Ekklesia source checkout. The install runs offline: third-party packages come from the
|
||||||
|
workspace install's own pnpm store, named explicitly because pnpm otherwise picks a store per
|
||||||
|
file system, so no registry is involved at all.
|
||||||
|
- **The lockfile matches the release.** It must record each artifact's own integrity. The module
|
||||||
|
then reinstalls from the lockfile alone with `--frozen-lockfile`.
|
||||||
|
- **The install has the right shape.** There is no `drizzle-orm`, and exactly one `react`, which
|
||||||
|
`@ekklesia/ui` resolves to as well. `@ekklesia/contracts` is installed only transitively: it is
|
||||||
|
not in the module's `package.json` and not directly under `node_modules/@ekklesia`. Every
|
||||||
|
installed `@ekklesia/*` package, `module-pack` included, is exactly the release version, and
|
||||||
|
the fixture pins `module-pack` exactly.
|
||||||
|
- **The module builds.** It typechecks with `skipLibCheck: false`, so every published declaration
|
||||||
|
it reaches is checked, then builds its backend and UI.
|
||||||
|
- **It packages and verifies.** It is packed with the release's own `ekklesia-pack-module`, with
|
||||||
|
no workspace file present. Core's verifier then verifies it. The package must:
|
||||||
|
- incorporate the train's packages at the release version;
|
||||||
|
- name the release's module-pack as its packager;
|
||||||
|
- credit no Ekklesia package and exactly one React;
|
||||||
|
- contain no local path.
|
||||||
|
|
||||||
|
## Reproducibility
|
||||||
|
|
||||||
|
Two builds of the same commit produce byte-identical tarballs, and CI proves it on every run.
|
||||||
|
|
||||||
|
- **pnpm normalizes the tar entries.** Every entry gets the fixed modification time 1985-10-26, owner
|
||||||
|
0, and a mode of 0644, or 0755 when executable. The gzip header records no time.
|
||||||
|
- **The build output is deterministic,** and source maps use relative paths.
|
||||||
|
- **One nondeterminism came from pnpm itself.** pnpm rewrites `workspace:` ranges concurrently and
|
||||||
|
inserts each dependency as its lookup resolves. A package with more than one `workspace:`
|
||||||
|
dependency in one field could therefore be packed with those keys in a different order. Builds
|
||||||
|
from two checkouts of one commit differed in exactly that way: `@ekklesia/module-pack`'s
|
||||||
|
`package.json` ordered `@ekklesia/module-sdk` and `@ekklesia/module-database` differently.
|
||||||
|
- **The fix is pnpm's own hook.** The pack-only `beforePacking` hook puts each dependency map back
|
||||||
|
in the source `package.json`'s key order. It changes no name, range, or field. The tarball stays
|
||||||
|
an ordinary `pnpm pack` tarball.
|
||||||
|
- **The inspection enforces it.** It fails any packed map whose order differs from its source.
|
||||||
|
- **The hook also drops `devDependencies`** from packed manifests. A consumer never installs them,
|
||||||
|
and a tooling package's test-only development dependencies named private workspace packages
|
||||||
|
outside the train.
|
||||||
|
|
||||||
|
**What reproducibility does not cover:**
|
||||||
|
- The module package the consumer proof builds is deterministic only for a fixed install layout.
|
||||||
|
pnpm names a `file:` tarball's install directory after its path, and bundlers keep those names in
|
||||||
|
comments. So the proof always installs from the same relative path. From a registry, the directory
|
||||||
|
is simply `<name>@<version>`.
|
||||||
|
- Byte identity is proven for the pinned toolchain: Node.js from `.node-version`, and pnpm from
|
||||||
|
`packageManager`. Another Node.js or zlib build may compress differently. Each artifact's
|
||||||
|
integrity is recorded either way, and that, not byte identity, is what a publication step must
|
||||||
|
verify.
|
||||||
|
|
||||||
|
## Publishing the train
|
||||||
|
|
||||||
|
The registry is the Gitea npm registry under the `Zayin` owner:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://git.zayin.net/api/packages/Zayin/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
| Who | May |
|
||||||
|
| --- | --- |
|
||||||
|
| Anyone, signed in or not | search, read metadata, download, and install `@ekklesia/*` |
|
||||||
|
| The publisher account, through the publish workflow | publish new versions and move dist-tags |
|
||||||
|
| Anyone else | nothing more: publishing needs the publisher team's package write permission |
|
||||||
|
|
||||||
|
### The workflow
|
||||||
|
|
||||||
|
`.github/workflows/publish-packages.yml` (Actions → Publish packages → Run workflow, on `main`,
|
||||||
|
with the release version). Nothing else can start it: no pull request or push does, and CI holds no
|
||||||
|
registry credential at all.
|
||||||
|
|
||||||
|
1. **The gate.** `package-release` runs the same credential-free gate as CI (build, reproduce,
|
||||||
|
consume) and hands the proven release on as a workflow artifact, with its manifest's SHA-256.
|
||||||
|
2. **The publish job.** It builds and installs nothing. It downloads the artifact and runs
|
||||||
|
`scripts/publish-package-train.ts` with the secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, which only
|
||||||
|
this step receives.
|
||||||
|
3. **The publication record** is kept as an artifact of the run.
|
||||||
|
|
||||||
|
### What the publication tool does
|
||||||
|
|
||||||
|
`scripts/publish-package-train.ts` publishes exactly the `.tgz` files in the release directory. It
|
||||||
|
never builds, packs, or edits a package, and it never deletes, unpublishes, or overwrites anything.
|
||||||
|
|
||||||
|
1. **Validates the whole train before sending anything.** The manifest must be the one the gate
|
||||||
|
proved (`--manifest-sha256`), for the requested version and this commit (`--revision`), from a
|
||||||
|
clean tree (`"modified": false`), with exactly the nine expected packages. Every artifact's file
|
||||||
|
name, size, SHA-256, integrity, `SHA256SUMS` line, and the `package.json` inside it must match.
|
||||||
|
2. **Publishes in dependency order.** Each package comes after the train packages it depends on,
|
||||||
|
then alphabetically. For each package:
|
||||||
|
- **absent:** `npm publish <file.tgz>` under the staging dist-tag `ekklesia-staging`, given the
|
||||||
|
artifact's absolute path (npm runs in a temporary directory of its own), after the file is
|
||||||
|
checked once more against the manifest. npm sends the file's own bytes and runs no script for a
|
||||||
|
file; the registry checks them against their integrity and stores them as they are;
|
||||||
|
- **present with the same bytes:** accepted as already published, and verified;
|
||||||
|
- **present with other bytes:** a hard stop. Nothing is deleted or overwritten, nothing after it
|
||||||
|
is published, and the version is burned ([Immutability](#immutability)).
|
||||||
|
3. **Reads every package back.** The registry's metadata must name the package and version, report
|
||||||
|
the artifact's integrity, and list the same `dependencies`, `peerDependencies`,
|
||||||
|
`optionalDependencies`, and `bin` as the tarball's `package.json` (npm writes `bin` paths without
|
||||||
|
a leading `./`). Its tarball URL must be on the configured registry, and the tarball it serves must
|
||||||
|
be the release artifact, byte for byte. With `--require-public-read`, an anonymous download must
|
||||||
|
give the same bytes too. A package is published only once all of that holds.
|
||||||
|
4. **Moves `latest` last.** Only when all nine are verified does `latest` move to the train, never
|
||||||
|
backwards past a newer version. The train is published when every package's `latest` agrees.
|
||||||
|
5. **Writes the publication record** (`--record`).
|
||||||
|
|
||||||
|
A rerun is always safe. It publishes only what is missing, accepts what is already there and
|
||||||
|
identical, and moves only the tags that are not yet right. A train interrupted after five packages,
|
||||||
|
or after four tags, finishes on the next run. No retry ever deletes anything.
|
||||||
|
|
||||||
|
### The publication record
|
||||||
|
|
||||||
|
Evidence that one train was published, not a second package catalog, and never a secret:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"publicationRecord": 1,
|
||||||
|
"packageTrain": { "version": "0.2.0", "revision": "<commit>", "manifestSha256": "<hex>" },
|
||||||
|
"registry": "https://git.zayin.net/api/packages/Zayin/npm/",
|
||||||
|
"finalTag": "latest",
|
||||||
|
"completedAt": "<ISO time>",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"name": "@ekklesia/contracts",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"file": "ekklesia-contracts-0.2.0.tgz",
|
||||||
|
"outcome": "published",
|
||||||
|
"artifact": { "sha256": "<hex>", "integrity": "sha512-…", "size": 87379 },
|
||||||
|
"registry": { "tarball": "<url>", "sha256": "<hex>", "integrity": "sha512-…", "publicRead": true },
|
||||||
|
"finalTag": "moved",
|
||||||
|
"distTags": { "ekklesia-staging": "0.2.0", "latest": "0.2.0" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`outcome` is `published` or `already-present`. `finalTag` is `moved`, `already-current`, or
|
||||||
|
`kept-newer` (a newer version already holds `latest`).
|
||||||
|
|
||||||
|
### Credentials
|
||||||
|
|
||||||
|
- **One credential:** `EKKLESIA_PACKAGES_PUBLISH_TOKEN`, a repository Actions secret, used only by
|
||||||
|
the publish step. Nobody needs a token to read or install.
|
||||||
|
- **The publisher** is a dedicated account, member only of a `Zayin` team whose one
|
||||||
|
permission is Packages: Write. Its token has only the `write:package` scope. Gitea's
|
||||||
|
`write:package` also allows deleting packages; the tool never does, and nothing else uses the
|
||||||
|
token.
|
||||||
|
- **No token in git or in an `.npmrc`.** The tool writes a temporary npm configuration that refers to
|
||||||
|
the variable (`_authToken=${EKKLESIA_PACKAGES_PUBLISH_TOKEN}`), ignores every user, global, and
|
||||||
|
project npm configuration, sends the token only to the registry itself, and removes the
|
||||||
|
configuration afterwards.
|
||||||
|
- **What remains trusted:** Gitea gives repository secrets to workflows run for pull requests from
|
||||||
|
branches of this same repository, so anyone who can push a branch here could read the secret
|
||||||
|
through a changed workflow. Keep write access to this repository to the maintainers.
|
||||||
|
|
||||||
|
### Immutability
|
||||||
|
|
||||||
|
Gitea refuses to upload a version that exists (409). But it lets anyone with package write
|
||||||
|
permission delete a version, deletion leaves nothing behind, and an owner's package cleanup rules
|
||||||
|
can delete versions on a schedule. So immutability is Ekklesia's policy, not the registry's
|
||||||
|
guarantee:
|
||||||
|
|
||||||
|
- **A published version is never deleted,** by anyone, for any reason short of a security incident.
|
||||||
|
- **A version number is never reused with other bytes.** A version found on the registry with other
|
||||||
|
bytes is burned: investigate how it got there, and publish the train under a new version.
|
||||||
|
- **No package cleanup rule** may be configured for the `Zayin` owner.
|
||||||
|
- **The publisher never deletes.** The tool has no delete, unpublish, or overwrite, and its tests
|
||||||
|
check that.
|
||||||
|
|
||||||
|
Module lockfiles record each package's integrity, so a replaced package would fail every install of
|
||||||
|
it, rather than install silently.
|
||||||
|
|
||||||
|
### Moving to another registry
|
||||||
|
|
||||||
|
Only configuration changes: the registry URL (the workflow's `EKKLESIA_PACKAGES_REGISTRY`), the
|
||||||
|
publisher credential, and modules' `@ekklesia:registry`. Modules then regenerate their lockfiles,
|
||||||
|
because Gitea's tarball URLs are its own. Package sources, the train, its manifests, and every
|
||||||
|
artifact already built stay as they are; the same bytes keep the same integrity.
|
||||||
|
|
||||||
|
### Proving the publication machinery
|
||||||
|
|
||||||
|
`pnpm test:package-publication` (`scripts/test-package-publication.sh`, the CI `package-publication`
|
||||||
|
job) publishes a real release train to a disposable Gitea 1.27.2 configured like the real one:
|
||||||
|
public owners, a publisher team, and an outsider. In CI it publishes the very artifact the
|
||||||
|
`package-release` job handed on, after checking its manifest hash. It proves:
|
||||||
|
|
||||||
|
- anonymous search, metadata, and download work;
|
||||||
|
- publishing without credentials, or by a user outside the publisher team, fails;
|
||||||
|
- the round trip is byte-identical, also anonymously;
|
||||||
|
- the registry refuses a second upload of a published version;
|
||||||
|
- a train interrupted before its sixth package resumes, verifying the five and publishing the rest,
|
||||||
|
with `latest` untouched until all nine are verified;
|
||||||
|
- a version already published with other bytes stops the train, deleting and overwriting nothing,
|
||||||
|
and publishing nothing after it, on every retry;
|
||||||
|
- a train interrupted while moving `latest` converges on a rerun, moving only the missing tags.
|
||||||
|
|
||||||
|
### The first publication
|
||||||
|
|
||||||
|
The first train published is **0.2.0**, the next platform release, not 0.1.2. The `v0.1.2` tag
|
||||||
|
predates the curated SDK and this release tooling, so packages built now and called 0.1.2 would
|
||||||
|
claim to be source that 0.1.2 never contained.
|
||||||
|
|
||||||
|
Before it, an administrator of the Gitea instance:
|
||||||
|
|
||||||
|
1. creates the dedicated publisher account;
|
||||||
|
2. creates a `Zayin` team, for example `package-publishers`, with only the Packages
|
||||||
|
unit at Write, and adds the account to it;
|
||||||
|
3. creates a token for the account with only `write:package`, and stores it as the repository
|
||||||
|
Actions secret `EKKLESIA_PACKAGES_PUBLISH_TOKEN`;
|
||||||
|
4. checks that the owner has no package cleanup rule.
|
||||||
|
|
||||||
|
Then the release proceeds as usual: the version-set pull request merges, a maintainer starts
|
||||||
|
Publish packages on `main` with that version, and tags the commit once the train is published.
|
||||||
|
|
||||||
@@ -484,7 +484,13 @@ before it is installed: its digest, its archive safety (no links, special files,
|
|||||||
escaping paths), every file's digest, and its identity and compatibility. The digest proves
|
escaping paths), every file's digest, and its identity and compatibility. The digest proves
|
||||||
integrity, not who published the package; signing is not implemented. The verifier loads the backend
|
integrity, not who published the package; signing is not implemented. The verifier loads the backend
|
||||||
in a short-lived process with an empty environment, so module code never sees a database URL or a
|
in a short-lived process with an empty environment, so module code never sees a database URL or a
|
||||||
secret while it is being checked.
|
secret while it is being checked. That load executes package code: `noexec` is retained on the
|
||||||
|
general `/tmp`, but native packages require the verifier's narrow, bounded execution scratch to be
|
||||||
|
executable. The one-shot hardened verifier container—not the scratch mount—is the isolation
|
||||||
|
boundary, and it is not claimed to be a perfect sandbox. Use the supported invocation in
|
||||||
|
[`deploy/README.md`](../../deploy/README.md#installed-modules), which also keeps the root filesystem
|
||||||
|
read-only, runs as the image's non-root user, disables networking and privilege escalation, and
|
||||||
|
drops Linux capabilities.
|
||||||
|
|
||||||
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
||||||
|
|
||||||
|
|||||||
@@ -598,26 +598,74 @@ CI / migration-smoke (pull_request)
|
|||||||
|
|
||||||
Ekklesia Module Package v1 (ADR 0049) is proven at three levels, all in `pnpm verify`:
|
Ekklesia Module Package v1 (ADR 0049) is proven at three levels, all in `pnpm verify`:
|
||||||
|
|
||||||
- `packages/contracts/tests/module-package.test.ts`: the `ekklesia.package.json` schema, target
|
- `packages/contracts/tests/module-package.test.ts`: the `ekklesia.package.json` schema, required
|
||||||
names, and the generated runtime `package.json`.
|
SPDX expression and `LICENSE` inventory entry, target names, and the generated runtime
|
||||||
|
`package.json`.
|
||||||
- `core/tests/module-package-verification.test.ts`: the verifier against hand-built archives,
|
- `core/tests/module-package-verification.test.ts`: the verifier against hand-built archives,
|
||||||
including what the packager never produces. A valid package verifies and extracts with normalized
|
including what the packager never produces. A valid package verifies and extracts with normalized
|
||||||
modes. A wrong digest, a corrupted or missing or unlisted file, disagreeing identities, an unknown
|
modes. A wrong digest, a corrupted or missing or unlisted file, disagreeing identities, an unknown
|
||||||
format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or legacy
|
format, unknown transitional metadata, an incompatible target, Node.js range, SDK, or legacy
|
||||||
database runtime, ABI metadata carrying a forbidden transitional runtime, malformed ABI schema
|
database runtime, ABI metadata carrying a forbidden transitional runtime, malformed ABI schema
|
||||||
metadata, a missing entrypoint, a backend that imports something the package lacks, absolute and
|
metadata, missing or malformed license metadata, missing, unlisted, duplicated, or corrupted
|
||||||
|
`LICENSE`, a missing entrypoint, a backend that imports something the package lacks, absolute and
|
||||||
`..` paths, symbolic and hard links, device nodes and FIFOs, setuid and setgid entries, a second
|
`..` paths, symbolic and hard links, device nodes and FIFOs, setuid and setgid entries, a second
|
||||||
top-level directory, and a duplicate entry each fail with their own sanitized code. The backend
|
top-level directory, and a duplicate entry each fail with their own sanitized code. The backend
|
||||||
load check runs in a real child process and selects the schema contract from the manifest.
|
load check runs in a real child process and selects the schema contract from the manifest.
|
||||||
|
- `packages/module-pack/tests/native-target.test.ts`: the `linux-x64-glibc` target, with a real
|
||||||
|
native dependency (`sharp`). The checked-in proof module (`tests/fixtures/native-module`) is
|
||||||
|
packed, and the test proves:
|
||||||
|
- the package carries exactly sharp's discovered runtime closure, as real files;
|
||||||
|
- nothing from another platform, and no development dependency, is included;
|
||||||
|
- `runtime` matches what the binaries declare;
|
||||||
|
- the reviewed libvips supplement is merged and labelled;
|
||||||
|
- two packs from unrelated copies are byte-identical.
|
||||||
|
|
||||||
|
Core's own verifier then accepts the package, and the extracted package runs a real image
|
||||||
|
operation in a separate process with an empty environment. Node's resolution hooks and the
|
||||||
|
process's memory map show that nothing resolves or loads from outside the package except the
|
||||||
|
host's system libraries.
|
||||||
|
- `packages/module-pack/tests/native-closure.test.ts` breaks the closure rules one at a time, with
|
||||||
|
fake native packages built around the real sharp addon's bytes:
|
||||||
|
- which platform packages are kept and which are left out;
|
||||||
|
- missing and foreign-platform dependencies;
|
||||||
|
- version conflicts, links, a foreign machine, and Node.js range conflicts;
|
||||||
|
- an unneeded native target.
|
||||||
|
- `packages/module-pack/tests/supplements.test.ts` proves ADR 0052's mechanism:
|
||||||
|
- the documented content-digest algorithm;
|
||||||
|
- that the loader refuses anything unreviewed;
|
||||||
|
- that supplement material is merged after the package's own and labelled;
|
||||||
|
- that a supplement for another version, digest, or licence fails closed;
|
||||||
|
- that the shipped libvips supplement is intact and pins the installed package exactly.
|
||||||
- `packages/module-pack/tests/`: the packager against generated fixture modules (payload contents,
|
- `packages/module-pack/tests/`: the packager against generated fixture modules (payload contents,
|
||||||
generated `package.json`, metadata, bundled backend with only built-in imports, refusal of native
|
generated `package.json`, metadata, bundled backend with only built-in imports, refusal of native
|
||||||
and platform-specific dependencies and of unknown or textless licences, notices, byte-identical
|
and platform-specific dependencies and of unknown or textless third-party licences, explicit
|
||||||
archives, normalized tar and gzip headers), and end to end against the built module template:
|
module SPDX and license-file validation, unchanged `LICENSE` bytes and inventory digest, notices,
|
||||||
|
byte-identical archives, normalized tar and gzip headers), and end to end against the built module template:
|
||||||
packed twice to identical bytes without changing the workspace's install state, verified by Core's
|
packed twice to identical bytes without changing the workspace's install state, verified by Core's
|
||||||
verifier with a deliberately different Core Drizzle version, extracted elsewhere, and loaded with
|
verifier with a deliberately different Core Drizzle version, extracted elsewhere, and loaded with
|
||||||
Node's own resolution hooks proving nothing outside the package is resolved. The package contains
|
Node's own resolution hooks proving nothing outside the package is resolved. The package contains
|
||||||
module-side Drizzle but no `transitional.databaseRuntime`. Turbo builds the template and Core
|
the repository's exact AGPL `LICENSE`, authoritative `AGPL-3.0-or-later` metadata, module-side
|
||||||
first, and runs the template's own tests (which rebuild its UI in place) before these.
|
Drizzle but no `transitional.databaseRuntime`, and no tooling-only SPDX parser dependency. Turbo
|
||||||
|
builds the template and Core first, and runs the template's own tests (which rebuild its UI in
|
||||||
|
place) before these.
|
||||||
|
|
||||||
|
## The public package train
|
||||||
|
|
||||||
|
`pnpm test:package-release` (the CI `package-release` job) builds the public `@ekklesia/*` package
|
||||||
|
train, proves a second build from another checkout of the commit byte-identical, and installs
|
||||||
|
exactly those tarballs into the standalone fixture module, offline, which must typecheck with
|
||||||
|
`skipLibCheck: false`, build, package, and verify. It needs a clean source tree; pass
|
||||||
|
`-- --allow-modified` to try uncommitted changes locally. The release rules themselves (set,
|
||||||
|
versions, manifest inspection, release directory, manifest bytes, the pack hook) are unit-tested in
|
||||||
|
`tests/public-packages.test.ts`, part of `pnpm verify`. See
|
||||||
|
[Releases and the public package train](releases.md).
|
||||||
|
|
||||||
|
`pnpm test:package-publication` (the CI `package-publication` job, given the `package-release` job's
|
||||||
|
artifact) publishes a release train to a disposable Gitea 1.27.2 with Docker, never the real
|
||||||
|
registry, and proves the publication tool: byte-identical round trips, anonymous reads, refused
|
||||||
|
unauthorized publishing, partial-train recovery, the hard stop on a burned version, and dist-tag
|
||||||
|
convergence (`tests/package-publication.integration.ts`). The tool's own rules are unit-tested in
|
||||||
|
`tests/package-publication.test.ts`, part of `pnpm verify`.
|
||||||
|
|
||||||
## The generic platform image and the reference deployment
|
## The generic platform image and the reference deployment
|
||||||
|
|
||||||
@@ -636,8 +684,9 @@ These need Docker and build images locally; nothing is pushed or published.
|
|||||||
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the reference deployment end to end on the
|
- `pnpm deploy:smoke` (`scripts/smoke-deploy.sh`) runs the reference deployment end to end on the
|
||||||
generic image with empty module storage. With `EKKLESIA_SMOKE_TOPOLOGY=split`, the same image also
|
generic image with empty module storage. With `EKKLESIA_SMOKE_TOPOLOGY=split`, the same image also
|
||||||
runs the installed-module lifecycle:
|
runs the installed-module lifecycle:
|
||||||
- the module template is packaged by the canonical packager (Module Package v1, ADR 0049),
|
- the module template is packaged by the canonical packager (Module Package v1, ADR 0049 and
|
||||||
as ABI v1, verified by the image's own verifier, and installed into module storage;
|
ADR 0051), with its explicit AGPL declaration and license bytes, as ABI v1, verified by the
|
||||||
|
image's own verifier, and installed into module storage;
|
||||||
- its migration is planned and explicitly applied, and the API restarted;
|
- its migration is planned and explicitly applied, and the API restarted;
|
||||||
- a real browser mounts it;
|
- a real browser mounts it;
|
||||||
- it is removed and the API restarted, and Core still runs, migrations still plan and apply, and
|
- it is removed and the API restarted, and Core still runs, migrations still plan and apply, and
|
||||||
|
|||||||
@@ -66,8 +66,126 @@ module with no migration history omits `entrypoints.migrations` and needs no pla
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The package version must match the manifest and runtime-definition versions. Internal package
|
The package version must match the manifest and runtime-definition versions. Inside this
|
||||||
dependencies use `workspace:` ranges and supported public package entrypoints.
|
repository, Ekklesia package dependencies use `workspace:` ranges and supported public package
|
||||||
|
entrypoints. A module in its own repository depends on the published packages instead
|
||||||
|
([Developing a module in its own repository](#developing-a-module-in-its-own-repository)).
|
||||||
|
|
||||||
|
### Developing a module in its own repository
|
||||||
|
|
||||||
|
> **Status:** the packages are published by release
|
||||||
|
> ([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md),
|
||||||
|
> [Releases and the public package train](../development/releases.md)). The first published train
|
||||||
|
> is 0.2.0, the platform release after 0.1.2.
|
||||||
|
|
||||||
|
A module does not need an Ekklesia checkout. Do not clone Ekklesia and put your module in its
|
||||||
|
`modules/` directory: your module is its own repository, and it depends on Ekklesia's
|
||||||
|
module-development packages like any other npm packages.
|
||||||
|
|
||||||
|
The packages are **publicly readable**
|
||||||
|
([ADR 0054](../architecture/decisions/0054-public-package-reads-before-launch.md)): installing them
|
||||||
|
needs no account, no token, and no access to Ekklesia's Gitea. That is not a public launch, and it
|
||||||
|
promises no API stability beyond the pre-1.0 SemVer policy. Only Ekklesia's release workflow can publish them. Point the `@ekklesia` scope at
|
||||||
|
the registry in your repository's committed `.npmrc`; that is all the configuration there is:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
@ekklesia:registry=https://git.zayin.net/api/packages/Zayin/npm/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the module builds and packages like any other:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
pnpm exec ekklesia-pack-module --module . --target any --out ../packages \
|
||||||
|
--license-file ./LICENSE --source-revision "$(git rev-parse HEAD)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Before 1.0, a minor version may change these APIs (the project's SemVer policy). Pin the train you
|
||||||
|
build against with ranges in `package.json` and exact versions in your lockfile.
|
||||||
|
|
||||||
|
| Package | Where it goes | What it is |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `@ekklesia/module-sdk` | `dependencies` | `defineModuleRuntime`, routes, configuration, storage, realtime, principals |
|
||||||
|
| `@ekklesia/module-database` | `dependencies` | Module Database ABI v1 types, `sql`, schema metadata; `/drizzle` for Drizzle |
|
||||||
|
| `@ekklesia/module-ui-sdk` | `dependencies` | `defineModuleUi` and the host context |
|
||||||
|
| `@ekklesia/design-tokens` | `dependencies` | design-token names and helpers |
|
||||||
|
| `@ekklesia/ui` | `dependencies` | shared elements; a pre-1.0 API that may still change |
|
||||||
|
| `react`, `react-dom`, your ORM if any | `dependencies` | your own copies, bundled into your artifacts |
|
||||||
|
| `@ekklesia/module-ui-build` | `devDependencies` | `ekklesia-build-module-ui` |
|
||||||
|
| `@ekklesia/module-pack` | `devDependencies`, **exact version** | `ekklesia-pack-module` |
|
||||||
|
| `@ekklesia/typescript-config` | `devDependencies` | `node.json`, `browser-react.json`, ... |
|
||||||
|
|
||||||
|
`@ekklesia/contracts` is not a direct dependency. The packages above install it; never add or
|
||||||
|
import it yourself, because what it exports is the platform's, not a supported author API.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@acme/notes",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": { "node": ">=24.11.0 <25" },
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json && ekklesia-build-module-ui"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/module-database": "^0.2.0",
|
||||||
|
"@ekklesia/module-sdk": "^0.2.0",
|
||||||
|
"@ekklesia/module-ui-sdk": "^0.2.0",
|
||||||
|
"@ekklesia/ui": "^0.2.0",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@ekklesia/module-pack": "0.2.0",
|
||||||
|
"@ekklesia/module-ui-build": "^0.2.0",
|
||||||
|
"@ekklesia/typescript-config": "^0.2.0",
|
||||||
|
"typescript": "7.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **One coordinated version.** Every Ekklesia module-development package has the same version,
|
||||||
|
equal to the Ekklesia release it belongs to, and the platform checks your manifest's
|
||||||
|
`compatibility.moduleSdk` against it. Ranges in your `package.json` are fine; your lockfile pins
|
||||||
|
exact artifacts, and you install with a frozen lockfile.
|
||||||
|
- **Pin `@ekklesia/module-pack` exactly** in `package.json`, not only in the lockfile: its version
|
||||||
|
decides the package it produces.
|
||||||
|
- **`engines.node` is required.** The packager records your module's supported Node.js range from
|
||||||
|
`engines.node` in your `package.json`, and refuses to package without it
|
||||||
|
(`MODULE_PACK_RUNTIME_UNKNOWN`). It never looks for a workspace file.
|
||||||
|
- **No Drizzle unless you use it.** The Module SDK does not install Drizzle. A module that uses the
|
||||||
|
Drizzle adapter, `@ekklesia/module-database/drizzle`, adds `drizzle-orm` to its own
|
||||||
|
dependencies; it is an optional peer of that adapter only.
|
||||||
|
- **One React.** `@ekklesia/ui` takes React as a peer, so your bundle contains one React, your own.
|
||||||
|
Your UI artifacts still bundle it
|
||||||
|
([ADR 0023](../architecture/decisions/0023-module-ui-build-pipeline.md)).
|
||||||
|
- **esbuild's install script.** The UI build and the packager use esbuild, which runs an install
|
||||||
|
script. With pnpm, allow it in your repository's `pnpm-workspace.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
```
|
||||||
|
- **Lockfiles name the registry.** pnpm records Gitea's tarball URLs in your lockfile. If the
|
||||||
|
packages ever move to another registry, change `@ekklesia:registry` and regenerate the lockfile;
|
||||||
|
nothing else changes, and the integrity of every package stays the same.
|
||||||
|
- **TypeScript.** Extend the shared configurations by package name, for example
|
||||||
|
`"extends": "@ekklesia/typescript-config/node.json"` for the backend and
|
||||||
|
`@ekklesia/typescript-config/browser-react.json` for the UI.
|
||||||
|
|
||||||
|
Packaging and verification work exactly as for a module in this repository
|
||||||
|
([Packaging your module](#packaging-your-module)). The packager recognizes Ekklesia's packages by
|
||||||
|
their identity, not by where they are installed, so they are recorded under `incorporates` and never
|
||||||
|
credited as third-party software, wherever they came from.
|
||||||
|
|
||||||
|
Ekklesia proves this on every CI run: `pnpm test:package-release` builds the public packages of a
|
||||||
|
release as one train of tarballs, proves two builds identical, and installs exactly those tarballs
|
||||||
|
into a module outside the repository (`tests/fixtures/standalone-module`), with no workspace link
|
||||||
|
and no registry, then typechecks, builds, packages, and verifies it. Those exact artifacts are what
|
||||||
|
the release publishes ([Releases and the public package train](../development/releases.md)).
|
||||||
|
|
||||||
### An installed module is self-contained
|
### An installed module is self-contained
|
||||||
|
|
||||||
@@ -92,15 +210,17 @@ through no link, `NODE_PATH`, loader hook, or alias. The packager therefore bund
|
|||||||
|
|
||||||
#### Packaging your module
|
#### Packaging your module
|
||||||
|
|
||||||
Build your module, then run the packager from the workspace your module is built in. It is a
|
Build your module, then run the packager from your module's directory. It is a development
|
||||||
development dependency like `@ekklesia/module-ui-build`:
|
dependency like `@ekklesia/module-ui-build`, and it takes your module's supported Node.js range from
|
||||||
|
`engines.node` in its `package.json`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm exec ekklesia-pack-module --module . --target any --out ../packages \
|
pnpm exec ekklesia-pack-module --module . --target any --out ../packages \
|
||||||
|
--license-file ./LICENSE \
|
||||||
--source-revision "$(git rev-parse HEAD)"
|
--source-revision "$(git rev-parse HEAD)"
|
||||||
```
|
```
|
||||||
|
|
||||||
It writes `<id>-<version>-any.tar.gz` and its `.sha256` line, and nothing else anywhere: it never
|
It writes `<id>-<version>-<target>.tar.gz` and its `.sha256` line, and nothing else anywhere: it never
|
||||||
installs, links, or deploys, so your workspace is left exactly as it was. Never run `pnpm deploy` in
|
installs, links, or deploys, so your workspace is left exactly as it was. Never run `pnpm deploy` in
|
||||||
a workspace instead: it rewrites that workspace's install state to production-only.
|
a workspace instead: it rewrites that workspace's install state to production-only.
|
||||||
|
|
||||||
@@ -110,13 +230,15 @@ The archive holds one directory named with your module id:
|
|||||||
<id>/
|
<id>/
|
||||||
├── package.json generated: name, version, private, "type": "module", the layer marker
|
├── package.json generated: name, version, private, "type": "module", the layer marker
|
||||||
├── ekklesia.module.json your manifest, byte for byte
|
├── ekklesia.module.json your manifest, byte for byte
|
||||||
├── ekklesia.package.json package metadata: target, runtime, provenance, file digests
|
├── ekklesia.package.json package metadata: license, target, runtime, provenance, file digests
|
||||||
|
├── LICENSE your explicitly supplied module license material, byte for byte
|
||||||
├── THIRD_PARTY_NOTICES licence and notice text of every third-party package bundled
|
├── THIRD_PARTY_NOTICES licence and notice text of every third-party package bundled
|
||||||
├── dist/server/index.js your backend entrypoints, bundled
|
├── dist/server/index.js your backend entrypoints, bundled
|
||||||
├── dist/schema/index.js
|
├── dist/schema/index.js
|
||||||
├── dist/chunks/ code your backend entrypoints share
|
├── dist/chunks/ code your backend entrypoints share
|
||||||
├── dist/ui/ … dist/display/ … dist/contributions/ your browser artifacts, exactly as built
|
├── dist/ui/ … dist/display/ … dist/contributions/ your browser artifacts, exactly as built
|
||||||
└── drizzle/ your migrations
|
├── drizzle/ your migrations
|
||||||
|
└── node_modules/ a native target only: native runtime dependencies, as real files
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Bundled backend.** Your server, schema, and jobs entrypoints are bundled with every
|
- **Bundled backend.** Your server, schema, and jobs entrypoints are bundled with every
|
||||||
@@ -127,14 +249,57 @@ The archive holds one directory named with your module id:
|
|||||||
your development `package.json`, any `workspace:` range, and links of any kind.
|
your development `package.json`, any `workspace:` range, and links of any kind.
|
||||||
- **Browser artifacts must be current.** The packager rebuilds them in memory and refuses an artifact
|
- **Browser artifacts must be current.** The packager rebuilds them in memory and refuses an artifact
|
||||||
on disk that differs, so run your build first.
|
on disk that differs, so run your build first.
|
||||||
- **`any` is the only target today.** A dependency that is native or platform-specific (a compiled
|
- **Targets.**
|
||||||
addon, a package that declares an operating system or CPU, or one that loads a per-platform binary
|
- **`any`** is for pure-JavaScript modules. A dependency that is native or platform-specific (a
|
||||||
such as `sharp`) cannot be in an `any` package, and packaging fails. Native targets come later.
|
compiled addon, a package that declares an operating system or CPU, or one that loads a
|
||||||
- **Licences.** `THIRD_PARTY_NOTICES` credits every third-party package your bundles contain, with
|
per-platform binary such as `sharp`) cannot be in an `any` package, and packaging fails.
|
||||||
its declared licence and the licence and notice files it ships. A package that declares no licence
|
- **`linux-x64-glibc`** is for modules with such a dependency, on Linux, x86-64, and glibc. Pack
|
||||||
fails packaging, and so does one that ships no licence text, unless its licence's standard text is
|
with `--target linux-x64-glibc` on a matching host, after installing your dependencies.
|
||||||
complete without a copyright line (Apache-2.0, which the packager then supplies and says so). The
|
- **What a `linux-x64-glibc` package contains.**
|
||||||
Ekklesia platform's own packages are listed in `ekklesia.package.json` under `incorporates`.
|
- **Native packages stay outside the bundle.** Your ordinary JavaScript dependencies are bundled
|
||||||
|
as before. Each native package is copied instead, with exactly its production runtime
|
||||||
|
dependencies, as real files flat under `node_modules/<name>`.
|
||||||
|
- **Only this target's platform packages are copied.** Optional platform packages for other
|
||||||
|
systems, development dependencies, declarations, and source maps are left out. Two versions of
|
||||||
|
one package, a link inside a native package, or a required dependency for another platform fail
|
||||||
|
packaging.
|
||||||
|
- **`runtime` is read from the binaries themselves.** It records the Node-API version your addons
|
||||||
|
declare (or the exact Node.js ABI of an ABI-specific addon) and the newest glibc symbol version
|
||||||
|
any copied binary needs. A native target for a module with no native addon is refused: use
|
||||||
|
`any`.
|
||||||
|
- **A native package fits one environment.** A `linux-x64-glibc` artifact runs only on Linux,
|
||||||
|
x86-64, glibc hosts that meet its recorded glibc and Node-API minimums. The verifier checks all of
|
||||||
|
that before it loads anything. Other operating systems and CPUs, including ARM, are not supported
|
||||||
|
yet.
|
||||||
|
- **Your module's licence.** Your source `package.json` must contain a non-empty string SPDX
|
||||||
|
expression. The packager validates its syntax, rejects `UNLICENSED`, and copies it unchanged to
|
||||||
|
the required `ekklesia.package.json.license`. `--license-file` is also required: it names the
|
||||||
|
regular file whose exact bytes become the root `LICENSE` payload and inventory entry. The
|
||||||
|
packager never searches parent directories, guesses text from the expression, or downloads it.
|
||||||
|
- **Third-party licences.** `THIRD_PARTY_NOTICES` separately credits every third-party package your
|
||||||
|
bundles contain, with its declared licence and the licence and notice files it ships. A dependency
|
||||||
|
that declares no licence fails packaging, and so does one that ships no licence text, unless its
|
||||||
|
licence's standard text is complete without a copyright line (Apache-2.0, which the packager then
|
||||||
|
supplies and says so). The module itself is not added to this notice file. Bundled Ekklesia
|
||||||
|
packages remain listed in `ekklesia.package.json` under `incorporates`; one applicable AGPL text
|
||||||
|
is sufficient for current official modules. An Ekklesia package is one named `@ekklesia/*` whose
|
||||||
|
`package.json` also carries `"ekklesia": { "distribution": "module-development" }`, whether it is
|
||||||
|
linked from this repository or installed from a registry; a package with only the name is
|
||||||
|
third-party.
|
||||||
|
- **Reviewed licence supplements.** Some dependencies omit material that redistribution requires.
|
||||||
|
For one of those, the packager adds material from a reviewed supplement checked in with it, after
|
||||||
|
the package's own material and labelled as such
|
||||||
|
([ADR 0052](../architecture/decisions/0052-reviewed-license-supplements.md)).
|
||||||
|
- **Exact match.** A supplement is used only for an exact package name, version, and content
|
||||||
|
digest. The digest covers the redistributed files' relative paths and bytes, and is documented in
|
||||||
|
`packages/module-pack/src/supplements.ts`. Any mismatch fails.
|
||||||
|
- **What it does not do.** A supplement never changes the licence the package declares. The
|
||||||
|
supplements cover `@img/sharp-libvips-linux-x64` 1.3.3 and 1.3.4, the libvips binaries that
|
||||||
|
`sharp` 0.35.4 and 0.35.5 load; each matches only its own version and digest, and the packager's
|
||||||
|
native proof builds with sharp 0.35.5. A supplement identifies and verifies licence and notice
|
||||||
|
material only. Source availability for copyleft native code is a separate requirement, met by a
|
||||||
|
corresponding-source artifact
|
||||||
|
([Distributing a native package publicly](#distributing-a-native-package-publicly)).
|
||||||
- **Deterministic.** The same packager version and the same built inputs produce the same archive,
|
- **Deterministic.** The same packager version and the same built inputs produce the same archive,
|
||||||
byte for byte, wherever the checkout is.
|
byte for byte, wherever the checkout is.
|
||||||
|
|
||||||
@@ -153,13 +318,113 @@ require exact agreement with Core. The legacy facade has not been removed.
|
|||||||
`node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an
|
`node core/dist/cli/verify-module-package.js <archive> --sha256-file <archive>.sha256`, run from an
|
||||||
Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without
|
Ekklesia installation (`/opt/ekklesia` in the image), checks a package against that platform without
|
||||||
installing it. Pass `--extract-to <new directory>` to receive the verified module directory.
|
installing it. Pass `--extract-to <new directory>` to receive the verified module directory.
|
||||||
[deploy/README.md](../../deploy/README.md#installed-modules) shows the whole installation.
|
[deploy/README.md](../../deploy/README.md#installed-modules) shows the supported hardened container
|
||||||
|
invocation; use it rather than inventing mount flags. Backend verification executes package code.
|
||||||
|
Its container is therefore the isolation boundary: general `/tmp` remains `noexec`, while a narrow,
|
||||||
|
bounded, ephemeral verifier scratch tmpfs permits execution so native addons can load.
|
||||||
|
|
||||||
The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An
|
The repository's boundary checks (`pnpm boundaries`) prove only source modules in a workspace. An
|
||||||
installed package normally has no source, so the platform cannot check it the same way. Your
|
installed package normally has no source, so the platform cannot check it the same way. Your
|
||||||
module's own CI is where its boundary rules, its production dependencies, and its behaviour inside
|
module's own CI is where its boundary rules, its production dependencies, and its behaviour inside
|
||||||
Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)).
|
Ekklesia are proven ([ADR 0047](../architecture/decisions/0047-module-ci-harness.md)).
|
||||||
|
|
||||||
|
#### Distributing a native package publicly
|
||||||
|
|
||||||
|
Building and testing a native package needs nothing beyond the packager. **Distributing it
|
||||||
|
publicly** does, when it redistributes native code under the GNU LGPL, the MPL, or a similar licence
|
||||||
|
with source obligations
|
||||||
|
([ADR 0055](../architecture/decisions/0055-corresponding-source-artifacts.md)).
|
||||||
|
|
||||||
|
- **Covered dependencies.** A native dependency is covered when its declared licence, or any
|
||||||
|
component its reviewed supplement records inside it, carries source obligations. libvips is one:
|
||||||
|
it declares `LGPL-3.0-or-later`, and cairo and several Rust crates inside it are under the MPL.
|
||||||
|
- **The policy.** A package with covered dependencies may be distributed publicly only together with
|
||||||
|
a corresponding-source artifact. The artifact holds the exact source, patches, build definitions,
|
||||||
|
and rebuild and relink instructions for those dependencies, as bytes rather than links. If the
|
||||||
|
artifact cannot be produced and verified, the package is not approved for public distribution.
|
||||||
|
This is Ekklesia's project compliance policy, not legal advice or certification. A package that
|
||||||
|
passes the gate meets Ekklesia's redistribution policy, and nothing more is claimed.
|
||||||
|
- **Source recipes.** For each covered dependency version, a reviewed recipe is checked in beside
|
||||||
|
the supplements, under `packages/module-pack/corresponding-sources/<package>/<version>/`. It holds
|
||||||
|
`recipe.json` and its documents, notably `BUILDING.md`. The recipe names the exact package (name,
|
||||||
|
version, content digest) and the exact supplement it was prepared with. It lists every component
|
||||||
|
with its role (`compiled`, `build-input`, or `build-definition`), licence, licence material, and
|
||||||
|
relationship to the binary. Every source file and patch is recorded by origin, size, and SHA-256,
|
||||||
|
and every exclusion has a reason. Like supplements, recipes are reviewed data, and the mechanism
|
||||||
|
knows no particular library.
|
||||||
|
|
||||||
|
Releasing a native package publicly takes three steps after packaging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Acquisition: the only step that uses the network. It fetches each recorded file once into a
|
||||||
|
# cache keyed by SHA-256, refusing any response that differs from the recipe.
|
||||||
|
pnpm exec ekklesia-corresponding-source acquire --package ../packages/<id>-<version>-linux-x64-glibc.tar.gz \
|
||||||
|
--cache ~/.cache/ekklesia/corresponding-sources
|
||||||
|
|
||||||
|
# 2. Construction: offline. Every byte is verified against the recipe before anything is written.
|
||||||
|
pnpm exec ekklesia-corresponding-source build --package ../packages/<id>-<version>-linux-x64-glibc.tar.gz \
|
||||||
|
--cache ~/.cache/ekklesia/corresponding-sources --out ../packages
|
||||||
|
|
||||||
|
# 3. The public-distribution gate. Exit status 0 means approved.
|
||||||
|
pnpm exec ekklesia-corresponding-source verify --package ../packages/<id>-<version>-linux-x64-glibc.tar.gz \
|
||||||
|
--source ../packages/<id>-<version>-linux-x64-glibc-corresponding-source.tar.gz \
|
||||||
|
--metadata ../packages/<id>-<version>-linux-x64-glibc.redistribution.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Construction writes four sibling release artifacts beside the package. The source archive never goes
|
||||||
|
inside the Module Package, and `THIRD_PARTY_NOTICES` is unchanged.
|
||||||
|
|
||||||
|
```text
|
||||||
|
<id>-<version>-linux-x64-glibc.tar.gz the Module Package (packager)
|
||||||
|
<id>-<version>-linux-x64-glibc.tar.gz.sha256
|
||||||
|
<id>-<version>-linux-x64-glibc-corresponding-source.tar.gz the corresponding source
|
||||||
|
<id>-<version>-linux-x64-glibc-corresponding-source.tar.gz.sha256
|
||||||
|
<id>-<version>-linux-x64-glibc.redistribution.json binary ↔ source, by digest
|
||||||
|
<id>-<version>-linux-x64-glibc.source-notice.txt the release notice
|
||||||
|
```
|
||||||
|
|
||||||
|
- **The source archive** is deterministic: sorted entries, fixed modes, ownership, and timestamps,
|
||||||
|
and no machine-specific path. The same package and recipe produce the same bytes in any directory.
|
||||||
|
- `corresponding-source.json` records every file with its SHA-256, and `SHA256SUMS` repeats them.
|
||||||
|
- Each covered dependency has its own `dependencies/<package>/<version>/` directory, with
|
||||||
|
`BUILDING.md`, the recipe, `PROVENANCE.md`, the supplement, and `sources/` and `patches/`.
|
||||||
|
- **The redistribution metadata** answers "which exact source corresponds to this exact binary?"
|
||||||
|
from digests. It records:
|
||||||
|
- the package file and its SHA-256, the module identity, and the target;
|
||||||
|
- each covered dependency's version and content digest, and the supplement and recipe it was
|
||||||
|
assembled from;
|
||||||
|
- the source archive's file name and SHA-256, and the SHA-256 of its manifest.
|
||||||
|
- **The release notice** must be shown alongside the binary wherever it is offered. It states that
|
||||||
|
the release includes third-party software under licences including the GNU LGPL and the MPL, and
|
||||||
|
that corresponding source and build and relink materials for the covered native dependencies are
|
||||||
|
in the accompanying artifact. It names that artifact by its exact file name and SHA-256.
|
||||||
|
- **Fail-closed.** The gate refuses a package when:
|
||||||
|
- a covered dependency has no recipe, or a recipe or supplement does not match its exact bytes;
|
||||||
|
- the source archive is missing, non-canonical, or differs from its manifest;
|
||||||
|
- the archive holds a file the manifest does not record, or a generated file names a local path;
|
||||||
|
- the metadata or the notice does not name these exact files.
|
||||||
|
|
||||||
|
It also refuses bundled (not copied) copyleft code, which this mechanism does not cover. A package
|
||||||
|
with no covered native code is approved without an artifact.
|
||||||
|
- **Retention.** Keep a release's source artifact for as long as its binary release is distributed
|
||||||
|
or supported. Never delete one automatically while the binary remains available. Never replace its
|
||||||
|
bytes under the same name. A correction is a new release, or an explicitly versioned compliance
|
||||||
|
artifact.
|
||||||
|
|
||||||
|
**Available today:** a recipe for `@img/sharp-libvips-linux-x64` 1.3.4, used by sharp 0.35.5. Its
|
||||||
|
artifact is about 200 MB: 27 library archives, 5 patches, 353 Rust crates, and the Rust standard
|
||||||
|
library source of the recorded toolchain. Following its `BUILDING.md` rebuilds
|
||||||
|
`libvips-cpp.so.8.18.7` from that source alone. libvips 1.3.3 (sharp 0.35.4) has a supplement but no
|
||||||
|
recipe, so a package with it builds and tests as before but is not approved for public distribution.
|
||||||
|
`pnpm test:corresponding-source` runs the whole proof for the native proof module: acquisition into a
|
||||||
|
verified cache, two byte-identical builds, the gate, and sharp running from the package.
|
||||||
|
|
||||||
|
**Adding a native dependency** follows the same path as libvips:
|
||||||
|
- curate and review its supplement (ADR 0052);
|
||||||
|
- curate and review its source recipe and `BUILDING.md`, recording every source input the
|
||||||
|
dependency's build fetches;
|
||||||
|
- prove both with the native packaging tests and `pnpm test:corresponding-source`.
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
Ekklesia is licensed under the GNU Affero General Public License, version 3 or (at your option) any
|
Ekklesia is licensed under the GNU Affero General Public License, version 3 or (at your option) any
|
||||||
@@ -167,10 +432,13 @@ later version, `AGPL-3.0-or-later` ([LICENSE](../../LICENSE)), and official Ekkl
|
|||||||
same license. Declare it in your module's development `package.json`
|
same license. Declare it in your module's development `package.json`
|
||||||
(`"license": "AGPL-3.0-or-later"`), as the module template does.
|
(`"license": "AGPL-3.0-or-later"`), as the module template does.
|
||||||
|
|
||||||
A Module Package v1 does not yet record a module's own license: its runtime `package.json` and
|
A Module Package v1 records that validated expression in its required
|
||||||
`ekklesia.package.json` have fixed fields, and its contents are fixed, by ADR 0049. Carrying the
|
`ekklesia.package.json.license` and contains the exact explicitly supplied bytes at `LICENSE`
|
||||||
module's license, and the license text for the Ekklesia code bundled into its backend, needs a
|
([ADR 0051](../architecture/decisions/0051-module-package-license-material.md)). The generated
|
||||||
change to that format.
|
runtime `package.json` is not authoritative for licensing. `THIRD_PARTY_NOTICES` remains only for
|
||||||
|
redistributed third-party dependencies, while bundled Ekklesia components remain represented by
|
||||||
|
`incorporates`. This strict-reader addition keeps `packageFormat` at `1`; older pre-0051 artifacts
|
||||||
|
without the field and payload fail closed.
|
||||||
|
|
||||||
### Removing a module keeps its data
|
### Removing a module keeps its data
|
||||||
|
|
||||||
@@ -200,8 +468,8 @@ without executing module code:
|
|||||||
"name": "Calendar",
|
"name": "Calendar",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"platform": "^0.1.0",
|
"platform": "^0.2.0",
|
||||||
"moduleSdk": "^0.1.0"
|
"moduleSdk": "^0.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"modules": {
|
"modules": {
|
||||||
@@ -1414,27 +1682,27 @@ export const events = moduleTable("events", {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
The production schema entrypoint in `src/schema/index.ts` wraps those same objects with the Module
|
The production schema entrypoint in `src/schema/index.ts` turns those same objects into the plain
|
||||||
SDK ownership marker:
|
schema metadata an ABI-v1 module declares:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { defineModuleSchema } from "@ekklesia/module-sdk";
|
import { moduleDrizzleSchemaMetadata } from "@ekklesia/module-database/drizzle";
|
||||||
import { events } from "./tables.js";
|
import { events } from "./tables.js";
|
||||||
|
|
||||||
export { events } from "./tables.js";
|
export { events } from "./tables.js";
|
||||||
|
|
||||||
export default defineModuleSchema({
|
export default moduleDrizzleSchemaMetadata("calendar", { events });
|
||||||
moduleId: "calendar",
|
|
||||||
tables: { events }
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A module without an ORM declares the same metadata directly with `defineModuleSchemaMetadata` from
|
||||||
|
`@ekklesia/module-database`. A legacy-v8 module's schema entrypoint default-exports
|
||||||
|
`{ moduleId, tables }` with the Drizzle table objects themselves.
|
||||||
|
|
||||||
The prefix callback converts the module ID to a snake-case namespace and prefixes every local table
|
The prefix callback converts the module ID to a snake-case namespace and prefixes every local table
|
||||||
name. Here the physical PostgreSQL name is `calendar_events`; `care-team` plus `members` becomes
|
name. Here the physical PostgreSQL name is `calendar_events`; `care-team` plus `members` becomes
|
||||||
`care_team_members`. Local names must be lowercase snake case, and the complete physical identifier
|
`care_team_members`. Local names must be lowercase snake case, and the complete physical identifier
|
||||||
must not exceed 63 characters. The SDK's `createModuleTable` provides the same convenience when a
|
must not exceed 63 characters. `createModuleTable` from `@ekklesia/module-database/drizzle` provides
|
||||||
generator-facing pure declaration file is not needed; Core validates built metadata independently
|
the same convenience; Core validates built metadata independently of either helper.
|
||||||
of either helper.
|
|
||||||
|
|
||||||
Schema definitions use PostgreSQL tables in the platform-managed default/public schema. They must
|
Schema definitions use PostgreSQL tables in the platform-managed default/public schema. They must
|
||||||
be side-effect-free: do not connect to a database, read configuration, or perform I/O during import.
|
be side-effect-free: do not connect to a database, read configuration, or perform I/O during import.
|
||||||
@@ -1467,8 +1735,8 @@ export default defineConfig({
|
|||||||
Keep `tables.ts` limited to Drizzle declarations so the stable generator can load it without
|
Keep `tables.ts` limited to Drizzle declarations so the stable generator can load it without
|
||||||
executing the ESM module wrapper. For an ABI-v1 module, `src/schema/index.ts` remains the production
|
executing the ESM module wrapper. For an ABI-v1 module, `src/schema/index.ts` remains the production
|
||||||
compiled schema entrypoint and converts those same table objects to plain metadata with
|
compiled schema entrypoint and converts those same table objects to plain metadata with
|
||||||
`moduleDrizzleSchemaMetadata`; tables are not declared twice. A legacy-v8 module continues to wrap
|
`moduleDrizzleSchemaMetadata`; tables are not declared twice. A legacy-v8 module continues to export
|
||||||
them with `defineModuleSchema` while that facade exists.
|
them as `{ moduleId, tables }` while that facade exists.
|
||||||
|
|
||||||
Run the module's `migration:generate` script with a lowercase snake-case name, review the generated
|
Run the module's `migration:generate` script with a lowercase snake-case name, review the generated
|
||||||
SQL and snapshot, and add the matching entry to `drizzle/meta/_ekklesia.json`. Every entry must
|
SQL and snapshot, and add the matching entry to `drizzle/meta/_ekklesia.json`. Every entry must
|
||||||
@@ -1592,6 +1860,15 @@ not Core.
|
|||||||
|
|
||||||
## Legacy database access
|
## Legacy database access
|
||||||
|
|
||||||
|
> **New modules use [Module Database ABI v1](#module-database-abi-v1).** Core still serves the
|
||||||
|
> legacy facade described here for its deprecation window
|
||||||
|
> ([ADR 0050](../architecture/decisions/0050-module-database-abi-v1.md)), so existing legacy modules
|
||||||
|
> keep running. But the Module SDK no longer exposes the facade's type
|
||||||
|
> ([ADR 0053](../architecture/decisions/0053-standalone-module-development-packages.md)): built
|
||||||
|
> against the current SDK, a module that declares no database sees `services.database` as `never`,
|
||||||
|
> and the example below no longer typechecks. Migrate to ABI v1 rather than reaching for the type
|
||||||
|
> elsewhere.
|
||||||
|
|
||||||
For a version 8 manifest that omits `database`, `context.services.database` — available in `start`, `stop`, and every authorized route handler,
|
For a version 8 manifest that omits `database`, `context.services.database` — available in `start`, `stop`, and every authorized route handler,
|
||||||
never in `register` — is a `ModuleDatabase`: a typed Drizzle query surface over exactly `select`,
|
never in `register` — is a `ModuleDatabase`: a typed Drizzle query surface over exactly `select`,
|
||||||
`selectDistinct`, `selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`,
|
`selectDistinct`, `selectDistinctOn`, `insert`, `update`, `delete`, `execute`, and `transaction`,
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ekklesia",
|
"name": "ekklesia",
|
||||||
"version": "0.1.2",
|
"version": "0.2.5",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -38,9 +38,13 @@
|
|||||||
"modules:pack": "turbo run build && node packages/module-pack/bin/ekklesia-pack-module.mjs",
|
"modules:pack": "turbo run build && node packages/module-pack/bin/ekklesia-pack-module.mjs",
|
||||||
"modules:validate": "turbo run build && node core/dist/cli/validate-modules.js",
|
"modules:validate": "turbo run build && node core/dist/cli/validate-modules.js",
|
||||||
"modules:verify-package": "turbo run build && node core/dist/cli/verify-module-package.js",
|
"modules:verify-package": "turbo run build && node core/dist/cli/verify-module-package.js",
|
||||||
|
"packages:release": "node scripts/package-release.ts --out .package-release",
|
||||||
"test": "turbo run test test:root",
|
"test": "turbo run test test:root",
|
||||||
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
|
"test:migrations:integration": "pnpm build && bash scripts/test-migrations-integration.sh",
|
||||||
"test:root": "vitest run --config vitest.config.ts",
|
"test:root": "vitest run --config vitest.config.ts",
|
||||||
|
"test:package-release": "bash scripts/test-package-release.sh",
|
||||||
|
"test:corresponding-source": "bash scripts/test-corresponding-source.sh",
|
||||||
|
"test:package-publication": "bash scripts/test-package-publication.sh",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"typecheck": "turbo run typecheck typecheck:root",
|
"typecheck": "turbo run typecheck typecheck:root",
|
||||||
"typecheck:root": "tsc --noEmit --project tsconfig.json",
|
"typecheck:root": "tsc --noEmit --project tsconfig.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/contracts",
|
"name": "@ekklesia/contracts",
|
||||||
"version": "0.1.2",
|
"version": "0.2.5",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -10,28 +10,33 @@
|
|||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./module-package": {
|
||||||
|
"types": "./dist/module-package.d.ts",
|
||||||
|
"import": "./dist/module-package.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project tsconfig.build.json",
|
"build": "tsc --project tsconfig.build.json",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/contracts",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "0.45.2",
|
|
||||||
"semver": "7.8.5",
|
"semver": "7.8.5",
|
||||||
|
"spdx-expression-parse": "5.0.0",
|
||||||
"zod": "4.4.3"
|
"zod": "4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.3",
|
"@biomejs/biome": "2.5.3",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.13.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
|
"@types/spdx-expression-parse": "4.0.0",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
"ekklesia": {
|
"ekklesia": {
|
||||||
"layer": "contracts"
|
"layer": "contracts",
|
||||||
|
"distribution": "module-development"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ export * from "./module-http.js";
|
|||||||
export * from "./module-lifecycle.js";
|
export * from "./module-lifecycle.js";
|
||||||
export * from "./module-manifest.js";
|
export * from "./module-manifest.js";
|
||||||
export * from "./module-migration.js";
|
export * from "./module-migration.js";
|
||||||
export * from "./module-package.js";
|
|
||||||
export * from "./module-realtime.js";
|
export * from "./module-realtime.js";
|
||||||
export * from "./module-runtime.js";
|
export * from "./module-runtime.js";
|
||||||
export * from "./module-schema.js";
|
|
||||||
export * from "./module-schema-metadata.js";
|
export * from "./module-schema-metadata.js";
|
||||||
export * from "./module-schema-names.js";
|
export * from "./module-schema-names.js";
|
||||||
export * from "./module-storage.js";
|
export * from "./module-storage.js";
|
||||||
|
|||||||
@@ -1,59 +1,19 @@
|
|||||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
||||||
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
||||||
|
|
||||||
/**
|
|
||||||
* A trusted backend module's typed Drizzle query surface — the same query-building methods a
|
|
||||||
* platform-owned Drizzle instance exposes, restricted to exactly the operations a module needs to
|
|
||||||
* read and write its own tables. Derived with `Pick` from the real `drizzle-orm/node-postgres`
|
|
||||||
* database type rather than hand-declared, so every method keeps Drizzle's own table-aware
|
|
||||||
* argument and result inference exactly as-is — a module calling `database.select().from(table)`
|
|
||||||
* or `database.insert(table).values(...)` gets the identical typed builder a direct Drizzle caller
|
|
||||||
* would, with no `any`/`unknown` erasure anywhere in the chain.
|
|
||||||
*
|
|
||||||
* Deliberately excludes everything `NodePgDatabase` or the `drizzle(...)` factory otherwise
|
|
||||||
* exposes: `$client` (the raw `pg` client/pool), `_` (internal session/dialect/schema state),
|
|
||||||
* `query` (a relational-query builder tied to one schema, which would have to span every
|
|
||||||
* installed module's tables to exist here at all), `$cache`, `$count`, `$with`, `with`, and
|
|
||||||
* `refreshMaterializedView`. There is no connection string, pool, session, or dialect object
|
|
||||||
* reachable through this type. Carries no schema generic — a module brings its own Drizzle table
|
|
||||||
* objects (`@ekklesia/module-sdk`'s `defineModuleSchema`) to each call, so there is no global
|
|
||||||
* relational schema spanning every installed module for this type to leak.
|
|
||||||
*
|
|
||||||
* This is an integration boundary, not a security sandbox: modules are trusted, compiled backend
|
|
||||||
* plugins that already run arbitrary code in this process, exactly like every other lifecycle hook
|
|
||||||
* and HTTP handler they define. `ModuleDatabase` exists to keep a module's code Drizzle-typed and
|
|
||||||
* decoupled from how the platform physically connects to PostgreSQL — not to defend against a
|
|
||||||
* malicious module, which this facade cannot do and does not attempt to do.
|
|
||||||
*/
|
|
||||||
export type ModuleDatabase = Pick<
|
|
||||||
NodePgDatabase,
|
|
||||||
| "select"
|
|
||||||
| "selectDistinct"
|
|
||||||
| "selectDistinctOn"
|
|
||||||
| "insert"
|
|
||||||
| "update"
|
|
||||||
| "delete"
|
|
||||||
| "execute"
|
|
||||||
| "transaction"
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The complete set of platform-provided services a trusted module's `start`/`stop` lifecycle hooks
|
* The complete set of platform-provided services a trusted module's `start`/`stop` lifecycle hooks
|
||||||
* and authorized HTTP route handlers receive — for this phase, exactly one: `database`. Frozen, and
|
* and authorized HTTP route handlers receive — for this phase, exactly one: `database`. For a
|
||||||
|
* module that declares `database: { abi: 1 }` it is the Module Database ABI v1 executor; for a
|
||||||
|
* legacy module it is Core's legacy facade, whose type Core alone owns, so this package never
|
||||||
|
* references an ORM (ADR 0053). Frozen, and
|
||||||
* never handed to a module's `register` hook (`ModuleRegistrationContext` has no `services` field):
|
* never handed to a module's `register` hook (`ModuleRegistrationContext` has no `services` field):
|
||||||
* registration stays declarative and database-free, exactly as before this contract existed.
|
* registration stays declarative and database-free, exactly as before this contract existed.
|
||||||
* Deliberately has no storage, jobs, configuration, secrets, or authorization field yet — those
|
* Deliberately has no storage, jobs, configuration, secrets, or authorization field yet — those
|
||||||
* remain out of scope until a later phase explicitly adds them here, one at a time, the same way
|
* remain out of scope until a later phase explicitly adds them here, one at a time, the same way
|
||||||
* `database` was added.
|
* `database` was added.
|
||||||
*/
|
*/
|
||||||
export interface ModuleRuntimeServices<
|
export interface ModuleRuntimeServices<Database = ModuleDatabaseExecutor> {
|
||||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
|
||||||
> {
|
|
||||||
readonly database: Database;
|
readonly database: Database;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LegacyModuleRuntimeServices = ModuleRuntimeServices<ModuleDatabase>;
|
|
||||||
export type ModuleDatabaseAbiRuntimeServices = ModuleRuntimeServices<ModuleDatabaseExecutor>;
|
export type ModuleDatabaseAbiRuntimeServices = ModuleRuntimeServices<ModuleDatabaseExecutor>;
|
||||||
export type AnyModuleRuntimeServices =
|
|
||||||
| LegacyModuleRuntimeServices
|
|
||||||
| ModuleDatabaseAbiRuntimeServices;
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|||||||
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
|
import { type ModuleId, type PermissionId, permissionIdSchema } from "./identifiers.js";
|
||||||
import type { EkklesiaPrincipal } from "./identity.js";
|
import type { EkklesiaPrincipal } from "./identity.js";
|
||||||
import type { ModuleConfiguration } from "./module-configuration.js";
|
import type { ModuleConfiguration } from "./module-configuration.js";
|
||||||
import type { ModuleDatabase, ModuleRuntimeServices } from "./module-database.js";
|
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||||
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
||||||
import type { ModuleRealtimePublisher } from "./module-realtime.js";
|
import type { ModuleRealtimePublisher } from "./module-realtime.js";
|
||||||
import type { ModuleStorage } from "./module-storage.js";
|
import type { ModuleStorage } from "./module-storage.js";
|
||||||
@@ -272,9 +272,7 @@ export function moduleRoutesOverlap(
|
|||||||
* behavior, and uncaught-handler sanitization; permission enforcement always completes before a
|
* behavior, and uncaught-handler sanitization; permission enforcement always completes before a
|
||||||
* handler — and therefore any parameter validation it performs — can run.
|
* handler — and therefore any parameter validation it performs — can run.
|
||||||
*/
|
*/
|
||||||
export interface ModuleRouteHandlerContext<
|
export interface ModuleRouteHandlerContext<Database = ModuleDatabaseExecutor> {
|
||||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
|
||||||
> {
|
|
||||||
readonly moduleId: ModuleId;
|
readonly moduleId: ModuleId;
|
||||||
readonly principal: EkklesiaPrincipal;
|
readonly principal: EkklesiaPrincipal;
|
||||||
readonly params: Readonly<Record<string, string>>;
|
readonly params: Readonly<Record<string, string>>;
|
||||||
@@ -296,9 +294,10 @@ export interface ModuleRouteHandlerContext<
|
|||||||
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
/** A framework-neutral Fetch handler: standard `Request` in, standard `Response` out. The full
|
||||||
* query string remains available through `Request.url`; there is no competing query or body
|
* query string remains available through `Request.url`; there is no competing query or body
|
||||||
* abstraction. */
|
* abstraction. */
|
||||||
export type ModuleRouteHandler<
|
export type ModuleRouteHandler<Database = ModuleDatabaseExecutor> = (
|
||||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
request: Request,
|
||||||
> = (request: Request, context: ModuleRouteHandlerContext<Database>) => Awaitable<Response>;
|
context: ModuleRouteHandlerContext<Database>,
|
||||||
|
) => Awaitable<Response>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a module passes to `http.route(...)` during `register`. `permission` is a plain string here
|
* What a module passes to `http.route(...)` during `register`. `permission` is a plain string here
|
||||||
@@ -313,9 +312,7 @@ export type ModuleRouteHandler<
|
|||||||
* path, CORS/origin policy, authentication behavior, grant/platform-owner state, middleware, audit
|
* path, CORS/origin policy, authentication behavior, grant/platform-owner state, middleware, audit
|
||||||
* metadata, another module's namespace, or an unprotected/no-permission route.
|
* metadata, another module's namespace, or an unprotected/no-permission route.
|
||||||
*/
|
*/
|
||||||
export interface ModuleRouteDefinitionInput<
|
export interface ModuleRouteDefinitionInput<Database = ModuleDatabaseExecutor> {
|
||||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
|
||||||
> {
|
|
||||||
readonly method: ModuleHttpMethod;
|
readonly method: ModuleHttpMethod;
|
||||||
readonly path: string;
|
readonly path: string;
|
||||||
readonly permission: string;
|
readonly permission: string;
|
||||||
@@ -331,9 +328,7 @@ export interface ModuleRouteDefinitionInput<
|
|||||||
|
|
||||||
/** The validated, canonical form of a `ModuleRouteDefinitionInput` — what `http.route` produces
|
/** The validated, canonical form of a `ModuleRouteDefinitionInput` — what `http.route` produces
|
||||||
* only after every field passes its own grammar. */
|
* only after every field passes its own grammar. */
|
||||||
export interface ModuleRouteDefinition<
|
export interface ModuleRouteDefinition<Database = ModuleDatabaseExecutor> {
|
||||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
|
||||||
> {
|
|
||||||
readonly method: ModuleHttpMethod;
|
readonly method: ModuleHttpMethod;
|
||||||
readonly path: ModuleRoutePath;
|
readonly path: ModuleRoutePath;
|
||||||
readonly permission: PermissionId;
|
readonly permission: PermissionId;
|
||||||
@@ -346,9 +341,7 @@ export interface ModuleRouteDefinition<
|
|||||||
|
|
||||||
/** The Fetch-based registrar exposed as `context.http` during `register` only — never on
|
/** The Fetch-based registrar exposed as `context.http` during `register` only — never on
|
||||||
* `ModuleRuntimeContext`, so `start`/`stop` cannot register a route after the fact. */
|
* `ModuleRuntimeContext`, so `start`/`stop` cannot register a route after the fact. */
|
||||||
export interface ModuleHttpRouteRegistrar<
|
export interface ModuleHttpRouteRegistrar<Database = ModuleDatabaseExecutor> {
|
||||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
|
||||||
> {
|
|
||||||
route(definition: ModuleRouteDefinitionInput<Database>): void;
|
route(definition: ModuleRouteDefinitionInput<Database>): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import parseSpdxExpression from "spdx-expression-parse";
|
||||||
import { moduleIdSchema } from "./identifiers.js";
|
import { moduleIdSchema } from "./identifiers.js";
|
||||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||||
|
|
||||||
@@ -11,11 +12,30 @@ import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const MODULE_PACKAGE_FORMAT = 1;
|
export const MODULE_PACKAGE_FORMAT = 1;
|
||||||
|
export const MODULE_PACKAGE_LICENSE_FILENAME = "LICENSE";
|
||||||
export const MODULE_PACKAGE_METADATA_FILENAME = "ekklesia.package.json";
|
export const MODULE_PACKAGE_METADATA_FILENAME = "ekklesia.package.json";
|
||||||
export const MODULE_PACKAGE_NOTICES_FILENAME = "THIRD_PARTY_NOTICES";
|
export const MODULE_PACKAGE_NOTICES_FILENAME = "THIRD_PARTY_NOTICES";
|
||||||
/** The portable target: a package with no native or platform-specific file. */
|
/** The portable target: a package with no native or platform-specific file. */
|
||||||
export const MODULE_PACKAGE_ANY_TARGET = "any";
|
export const MODULE_PACKAGE_ANY_TARGET = "any";
|
||||||
|
|
||||||
|
/** The module publisher's package-level license declaration (ADR 0051). This validates SPDX syntax,
|
||||||
|
* not whether Ekklesia approves, recommends, or considers the license legally compatible. */
|
||||||
|
export const modulePackageLicenseExpressionSchema = z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(1024)
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
if (value === "UNLICENSED") {
|
||||||
|
context.addIssue({ code: "custom", message: "Must be an SPDX license expression." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parseSpdxExpression(value);
|
||||||
|
} catch {
|
||||||
|
context.addIssue({ code: "custom", message: "Must be an SPDX license expression." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `any`, or `<platform>-<arch>[-<libc>]` in Node.js's own `process.platform` and `process.arch`
|
* `any`, or `<platform>-<arch>[-<libc>]` in Node.js's own `process.platform` and `process.arch`
|
||||||
* vocabulary. On Linux the C library is required, because a native addon built against one does
|
* vocabulary. On Linux the C library is required, because a native addon built against one does
|
||||||
@@ -104,6 +124,8 @@ export const modulePackageMetadataSchema = z
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
packageFormat: z.literal(MODULE_PACKAGE_FORMAT),
|
packageFormat: z.literal(MODULE_PACKAGE_FORMAT),
|
||||||
module: z.strictObject({ id: moduleIdSchema, version: semanticVersionSchema }),
|
module: z.strictObject({ id: moduleIdSchema, version: semanticVersionSchema }),
|
||||||
|
/** The module's authoritative package-release license declaration (ADR 0051). */
|
||||||
|
license: modulePackageLicenseExpressionSchema,
|
||||||
target: modulePackageTargetSchema,
|
target: modulePackageTargetSchema,
|
||||||
runtime: modulePackageRuntimeSchema,
|
runtime: modulePackageRuntimeSchema,
|
||||||
provenance: z.strictObject({
|
provenance: z.strictObject({
|
||||||
@@ -124,6 +146,16 @@ export const modulePackageMetadataSchema = z
|
|||||||
files: z.array(modulePackageFileSchema),
|
files: z.array(modulePackageFileSchema),
|
||||||
})
|
})
|
||||||
.superRefine((metadata, context) => {
|
.superRefine((metadata, context) => {
|
||||||
|
const licenseFiles = metadata.files.filter(
|
||||||
|
(file) => file.path === MODULE_PACKAGE_LICENSE_FILENAME,
|
||||||
|
);
|
||||||
|
if (licenseFiles.length !== 1 || licenseFiles[0]?.size === 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "The payload must contain exactly one non-empty root LICENSE file.",
|
||||||
|
path: ["files"],
|
||||||
|
});
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
metadata.target === MODULE_PACKAGE_ANY_TARGET &&
|
metadata.target === MODULE_PACKAGE_ANY_TARGET &&
|
||||||
(metadata.runtime.nodeApi !== undefined ||
|
(metadata.runtime.nodeApi !== undefined ||
|
||||||
@@ -136,6 +168,24 @@ export const modulePackageMetadataSchema = z
|
|||||||
path: ["runtime"],
|
path: ["runtime"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (metadata.target !== MODULE_PACKAGE_ANY_TARGET) {
|
||||||
|
// ADR 0049: a native package states what its addons need from Node.js and, on Linux, the
|
||||||
|
// minimum C library version.
|
||||||
|
if (metadata.runtime.nodeApi === undefined && metadata.runtime.nodeAbi === undefined) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "A native package states the Node-API version or Node.js ABI its addons need.",
|
||||||
|
path: ["runtime"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (metadata.target.startsWith("linux-") && metadata.runtime.libcVersion === undefined) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "A native Linux package states the minimum C library version it needs.",
|
||||||
|
path: ["runtime", "libcVersion"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
for (let index = 1; index < metadata.files.length; index += 1) {
|
for (let index = 1; index < metadata.files.length; index += 1) {
|
||||||
const previous = metadata.files[index - 1]?.path ?? "";
|
const previous = metadata.files[index - 1]?.path ?? "";
|
||||||
const current = metadata.files[index]?.path ?? "";
|
const current = metadata.files[index]?.path ?? "";
|
||||||
@@ -157,6 +207,10 @@ export function safeParseModulePackageMetadata(input: unknown) {
|
|||||||
return modulePackageMetadataSchema.safeParse(input);
|
return modulePackageMetadataSchema.safeParse(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function safeParseModulePackageLicenseExpression(input: unknown) {
|
||||||
|
return modulePackageLicenseExpressionSchema.safeParse(input);
|
||||||
|
}
|
||||||
|
|
||||||
export function safeParseModulePackageTarget(input: unknown) {
|
export function safeParseModulePackageTarget(input: unknown) {
|
||||||
return modulePackageTargetSchema.safeParse(input);
|
return modulePackageTargetSchema.safeParse(input);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
||||||
import { moduleIdSchema } from "./identifiers.js";
|
import { moduleIdSchema } from "./identifiers.js";
|
||||||
import type { ModuleConfiguration } from "./module-configuration.js";
|
import type { ModuleConfiguration } from "./module-configuration.js";
|
||||||
import type { ModuleDatabase, ModuleRuntimeServices } from "./module-database.js";
|
import type { ModuleRuntimeServices } from "./module-database.js";
|
||||||
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
import type { ModuleDatabaseExecutor } from "./module-database-abi.js";
|
||||||
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
|
import type { ModuleHttpRouteRegistrar } from "./module-http.js";
|
||||||
import type { ModuleRealtimeChannelRegistrar, ModuleRealtimePublisher } from "./module-realtime.js";
|
import type { ModuleRealtimeChannelRegistrar, ModuleRealtimePublisher } from "./module-realtime.js";
|
||||||
@@ -25,9 +25,7 @@ export interface CapabilityRegistrar extends CapabilityResolver {
|
|||||||
provide(capabilityId: CapabilityId, implementation: unknown): void;
|
provide(capabilityId: CapabilityId, implementation: unknown): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleRegistrationContext<
|
export interface ModuleRegistrationContext<Database = ModuleDatabaseExecutor> {
|
||||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
|
||||||
> {
|
|
||||||
readonly capabilities: CapabilityRegistrar;
|
readonly capabilities: CapabilityRegistrar;
|
||||||
/** Available only during `register` — never on `ModuleRuntimeContext` — so a route can only ever
|
/** Available only during `register` — never on `ModuleRuntimeContext` — so a route can only ever
|
||||||
* be declared while Core is still building the immutable startup snapshot, never added or removed
|
* be declared while Core is still building the immutable startup snapshot, never added or removed
|
||||||
@@ -52,9 +50,7 @@ export interface ModuleRegistrationContext<
|
|||||||
* same frozen `services` object identity as the other, for a given API runtime — never a fresh
|
* same frozen `services` object identity as the other, for a given API runtime — never a fresh
|
||||||
* `ModuleRuntimeServices` built per hook invocation. Unlike `ModuleRegistrationContext`, this never
|
* `ModuleRuntimeServices` built per hook invocation. Unlike `ModuleRegistrationContext`, this never
|
||||||
* carries `http`: a route can only ever be declared during `register`. */
|
* carries `http`: a route can only ever be declared during `register`. */
|
||||||
export interface ModuleRuntimeContext<
|
export interface ModuleRuntimeContext<Database = ModuleDatabaseExecutor> {
|
||||||
Database extends ModuleDatabase | ModuleDatabaseExecutor = ModuleDatabase,
|
|
||||||
> {
|
|
||||||
readonly capabilities: CapabilityResolver;
|
readonly capabilities: CapabilityResolver;
|
||||||
readonly moduleId: ModuleId;
|
readonly moduleId: ModuleId;
|
||||||
readonly services: ModuleRuntimeServices<Database>;
|
readonly services: ModuleRuntimeServices<Database>;
|
||||||
@@ -87,7 +83,7 @@ export interface ModuleRuntimeContext<
|
|||||||
readonly config: ModuleConfiguration;
|
readonly config: ModuleConfiguration;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModuleRuntimeDefinitionBase<Database extends ModuleDatabase | ModuleDatabaseExecutor> {
|
interface ModuleRuntimeDefinitionBase<Database> {
|
||||||
readonly id: ModuleId;
|
readonly id: ModuleId;
|
||||||
readonly register?: (context: ModuleRegistrationContext<Database>) => Awaitable<void>;
|
readonly register?: (context: ModuleRegistrationContext<Database>) => Awaitable<void>;
|
||||||
readonly start?: (context: ModuleRuntimeContext<Database>) => Awaitable<void>;
|
readonly start?: (context: ModuleRuntimeContext<Database>) => Awaitable<void>;
|
||||||
@@ -95,7 +91,10 @@ interface ModuleRuntimeDefinitionBase<Database extends ModuleDatabase | ModuleDa
|
|||||||
readonly version: string;
|
readonly version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LegacyModuleRuntimeDefinition extends ModuleRuntimeDefinitionBase<ModuleDatabase> {
|
/** A module on the legacy database facade (ADR 0016), until its deprecation window ends (ADR 0050).
|
||||||
|
* The facade's type is Core's own, so here its database is opaque: Core specializes `Database`. */
|
||||||
|
export interface LegacyModuleRuntimeDefinition<Database = unknown>
|
||||||
|
extends ModuleRuntimeDefinitionBase<Database> {
|
||||||
readonly database?: never;
|
readonly database?: never;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,28 @@ export class ModuleStorageError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `value` is a module-storage error, judged by its stable public shape rather than its class.
|
||||||
|
*
|
||||||
|
* An installed module bundles its own copy of the contracts package (ADR 0049), so an error created
|
||||||
|
* by the host's storage provider does not share class identity with the module's
|
||||||
|
* `ModuleStorageError`. `instanceof ModuleStorageError` is therefore not a valid check across the
|
||||||
|
* module boundary.
|
||||||
|
*/
|
||||||
|
export function isModuleStorageError(
|
||||||
|
value: unknown,
|
||||||
|
): value is Pick<ModuleStorageError, "code" | "key" | "message" | "name"> {
|
||||||
|
if (!(value instanceof Error) || value.name !== "ModuleStorageError") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { code, key } = value as { code?: unknown; key?: unknown };
|
||||||
|
return (
|
||||||
|
typeof code === "string" &&
|
||||||
|
(MODULE_STORAGE_ERROR_CODES as readonly string[]).includes(code) &&
|
||||||
|
(key === undefined || typeof key === "string")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes,
|
/** The S3 object-key ceiling, which every S3-compatible provider shares. Measured in UTF-8 bytes,
|
||||||
* not characters, because that is what the wire format bounds. */
|
* not characters, because that is what the wire format bounds. */
|
||||||
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
|
export const MODULE_STORAGE_KEY_MAX_BYTES = 1024;
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
MODULE_PACKAGE_FORMAT,
|
MODULE_PACKAGE_FORMAT,
|
||||||
|
MODULE_PACKAGE_LICENSE_FILENAME,
|
||||||
MODULE_PACKAGE_METADATA_FILENAME,
|
MODULE_PACKAGE_METADATA_FILENAME,
|
||||||
modulePackageArchiveFileName,
|
modulePackageArchiveFileName,
|
||||||
modulePackageRuntimePackage,
|
modulePackageRuntimePackage,
|
||||||
|
safeParseModulePackageLicenseExpression,
|
||||||
safeParseModulePackageMetadata,
|
safeParseModulePackageMetadata,
|
||||||
safeParseModulePackageRuntimePackage,
|
safeParseModulePackageRuntimePackage,
|
||||||
safeParseModulePackageTarget,
|
safeParseModulePackageTarget,
|
||||||
} from "../src/index.js";
|
} from "../src/module-package.js";
|
||||||
|
|
||||||
const digest = "a".repeat(64);
|
const digest = "a".repeat(64);
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
|
|||||||
return {
|
return {
|
||||||
packageFormat: 1,
|
packageFormat: 1,
|
||||||
module: { id: "example", version: "0.1.0" },
|
module: { id: "example", version: "0.1.0" },
|
||||||
|
license: "AGPL-3.0-or-later",
|
||||||
target: "any",
|
target: "any",
|
||||||
runtime: { node: ">=24.11.0 <25" },
|
runtime: { node: ">=24.11.0 <25" },
|
||||||
provenance: {
|
provenance: {
|
||||||
@@ -24,6 +27,7 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
|
|||||||
incorporates: { "@ekklesia/contracts": "0.1.2", "@ekklesia/module-sdk": "0.1.2" },
|
incorporates: { "@ekklesia/contracts": "0.1.2", "@ekklesia/module-sdk": "0.1.2" },
|
||||||
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
transitional: { databaseRuntime: { "drizzle-orm": "0.45.2" } },
|
||||||
files: [
|
files: [
|
||||||
|
{ path: "LICENSE", size: 9, sha256: digest },
|
||||||
{ path: "THIRD_PARTY_NOTICES", size: 10, sha256: digest },
|
{ path: "THIRD_PARTY_NOTICES", size: 10, sha256: digest },
|
||||||
{ path: "dist/server/index.js", size: 20, sha256: digest },
|
{ path: "dist/server/index.js", size: 20, sha256: digest },
|
||||||
{ path: "ekklesia.module.json", size: 30, sha256: digest },
|
{ path: "ekklesia.module.json", size: 30, sha256: digest },
|
||||||
@@ -36,12 +40,46 @@ function metadata(overrides: Record<string, unknown> = {}): Record<string, unkno
|
|||||||
describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
|
describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
|
||||||
it("names the format, its metadata file, and the recommended archive file name", () => {
|
it("names the format, its metadata file, and the recommended archive file name", () => {
|
||||||
expect(MODULE_PACKAGE_FORMAT).toBe(1);
|
expect(MODULE_PACKAGE_FORMAT).toBe(1);
|
||||||
|
expect(MODULE_PACKAGE_LICENSE_FILENAME).toBe("LICENSE");
|
||||||
expect(MODULE_PACKAGE_METADATA_FILENAME).toBe("ekklesia.package.json");
|
expect(MODULE_PACKAGE_METADATA_FILENAME).toBe("ekklesia.package.json");
|
||||||
expect(modulePackageArchiveFileName("example", "0.1.0", "any")).toBe(
|
expect(modulePackageArchiveFileName("example", "0.1.0", "any")).toBe(
|
||||||
"example-0.1.0-any.tar.gz",
|
"example-0.1.0-any.tar.gz",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts SPDX license expressions and rejects non-expressions without a policy allowlist", () => {
|
||||||
|
for (const expression of [
|
||||||
|
"AGPL-3.0-or-later",
|
||||||
|
"MIT OR Apache-2.0",
|
||||||
|
"(MIT OR Apache-2.0) AND BSD-3-Clause",
|
||||||
|
]) {
|
||||||
|
expect(safeParseModulePackageLicenseExpression(expression).success, expression).toBe(true);
|
||||||
|
}
|
||||||
|
for (const expression of ["", "UNLICENSED", "MIT OR", "not a license", {}]) {
|
||||||
|
expect(safeParseModulePackageLicenseExpression(expression).success, String(expression)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the package license declaration and canonical LICENSE payload entry", () => {
|
||||||
|
const withoutLicense = metadata();
|
||||||
|
delete withoutLicense.license;
|
||||||
|
expect(safeParseModulePackageMetadata(withoutLicense).success).toBe(false);
|
||||||
|
|
||||||
|
const withoutLicenseFile = metadata();
|
||||||
|
withoutLicenseFile.files = (withoutLicenseFile.files as { path: string }[]).filter(
|
||||||
|
(file) => file.path !== "LICENSE",
|
||||||
|
);
|
||||||
|
expect(safeParseModulePackageMetadata(withoutLicenseFile).success).toBe(false);
|
||||||
|
|
||||||
|
const emptyLicenseFile = metadata();
|
||||||
|
emptyLicenseFile.files = (emptyLicenseFile.files as { path: string; size: number }[]).map(
|
||||||
|
(file) => (file.path === "LICENSE" ? { ...file, size: 0 } : file),
|
||||||
|
);
|
||||||
|
expect(safeParseModulePackageMetadata(emptyLicenseFile).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts a complete any-target package description", () => {
|
it("accepts a complete any-target package description", () => {
|
||||||
expect(safeParseModulePackageMetadata(metadata()).success).toBe(true);
|
expect(safeParseModulePackageMetadata(metadata()).success).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -97,7 +135,9 @@ describe("Ekklesia Module Package v1 metadata (ADR 0049)", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
// Byte-wise, not locale order: upper case sorts before lower case.
|
// Byte-wise, not locale order: upper case sorts before lower case.
|
||||||
expect(
|
expect(
|
||||||
safeParseModulePackageMetadata(metadata({ files: [entry("THIRD"), entry("dist")] })).success,
|
safeParseModulePackageMetadata(
|
||||||
|
metadata({ files: [entry("LICENSE"), entry("THIRD"), entry("dist")] }),
|
||||||
|
).success,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { alias, getTableConfig, integer } from "drizzle-orm/pg-core";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { moduleTableName, physicalModuleTableNameSchema } from "../src/index.js";
|
||||||
moduleTableName,
|
|
||||||
parseModuleSchemaDefinition,
|
|
||||||
physicalModuleTableNameSchema,
|
|
||||||
} from "../src/index.js";
|
|
||||||
import { pgTable } from "drizzle-orm/pg-core";
|
|
||||||
|
|
||||||
describe("module schema contract", () => {
|
describe("module schema contract", () => {
|
||||||
it("creates deterministic physical names from module and local identifiers", () => {
|
it("creates deterministic physical names from module and local identifiers", () => {
|
||||||
@@ -22,33 +16,4 @@ describe("module schema contract", () => {
|
|||||||
"The namespaced PostgreSQL table name is invalid or too long.",
|
"The namespaced PostgreSQL table name is invalid or too long.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes and freezes a typed schema definition without freezing its tables", () => {
|
|
||||||
const records = pgTable("example_records", { id: integer().primaryKey() });
|
|
||||||
const definition = parseModuleSchemaDefinition({
|
|
||||||
moduleId: "example",
|
|
||||||
tables: { records },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(definition.tables.records).toBe(records);
|
|
||||||
expect(getTableConfig(records).name).toBe("example_records");
|
|
||||||
expect(Object.isFrozen(definition)).toBe(true);
|
|
||||||
expect(Object.isFrozen(definition.tables)).toBe(true);
|
|
||||||
expect(Object.isFrozen(records)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects malformed, empty, unknown, and non-PostgreSQL table definitions", () => {
|
|
||||||
const records = pgTable("example_records", { id: integer() });
|
|
||||||
const disguisedRecords = alias(records, "example_disguised_records");
|
|
||||||
expect(() => parseModuleSchemaDefinition({ moduleId: "example", tables: {} })).toThrow();
|
|
||||||
expect(() =>
|
|
||||||
parseModuleSchemaDefinition({ moduleId: "example", tables: { records: {} } }),
|
|
||||||
).toThrow();
|
|
||||||
expect(() =>
|
|
||||||
parseModuleSchemaDefinition({ moduleId: "example", tables: { records }, extra: true }),
|
|
||||||
).toThrow();
|
|
||||||
expect(() =>
|
|
||||||
parseModuleSchemaDefinition({ moduleId: "example", tables: { disguisedRecords } }),
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/design-tokens",
|
"name": "@ekklesia/design-tokens",
|
||||||
"version": "0.1.2",
|
"version": "0.2.5",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project tsconfig.build.json",
|
"build": "tsc --project tsconfig.build.json",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/design-tokens",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
|
||||||
},
|
},
|
||||||
@@ -28,6 +27,7 @@
|
|||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
"ekklesia": {
|
"ekklesia": {
|
||||||
"layer": "design-tokens"
|
"layer": "design-tokens",
|
||||||
|
"distribution": "module-development"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ekklesia/module-database",
|
"name": "@ekklesia/module-database",
|
||||||
"version": "0.1.2",
|
"version": "0.2.5",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --project tsconfig.build.json",
|
"build": "tsc --project tsconfig.build.json",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-database",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||||
},
|
},
|
||||||
@@ -34,7 +33,8 @@
|
|||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
},
|
},
|
||||||
"ekklesia": {
|
"ekklesia": {
|
||||||
"layer": "module-sdk"
|
"layer": "module-sdk",
|
||||||
|
"distribution": "module-development"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"drizzle-orm": ">=0.45.2 <0.46.0"
|
"drizzle-orm": ">=0.45.2 <0.46.0"
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import type {
|
|||||||
ModuleId,
|
ModuleId,
|
||||||
ModuleSchemaMetadataDefinition,
|
ModuleSchemaMetadataDefinition,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
import { parseModuleSchemaMetadataDefinition } from "@ekklesia/contracts";
|
import {
|
||||||
import { type AnyPgTable, getTableConfig } from "drizzle-orm/pg-core";
|
moduleTableName,
|
||||||
|
moduleTablePrefix,
|
||||||
|
parseModuleSchemaMetadataDefinition,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { type AnyPgTable, getTableConfig, pgTableCreator } from "drizzle-orm/pg-core";
|
||||||
import { drizzle, type PgRemoteDatabase } from "drizzle-orm/pg-proxy";
|
import { drizzle, type PgRemoteDatabase } from "drizzle-orm/pg-proxy";
|
||||||
import type { DrizzleConfig } from "drizzle-orm/utils";
|
import type { DrizzleConfig } from "drizzle-orm/utils";
|
||||||
|
|
||||||
@@ -110,3 +114,13 @@ export function moduleDrizzleSchemaMetadata(
|
|||||||
});
|
});
|
||||||
return parseModuleSchemaMetadataDefinition({ moduleId, tables: metadata });
|
return parseModuleSchemaMetadataDefinition({ moduleId, tables: metadata });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Drizzle table factory for this module's tables: every table it creates gets the module's
|
||||||
|
* mandatory namespace (ADR 0007), so `createModuleTable("care-team")("members", …)` is the physical
|
||||||
|
* table `care_team_members`.
|
||||||
|
*/
|
||||||
|
export function createModuleTable(moduleId: ModuleId) {
|
||||||
|
moduleTablePrefix(moduleId);
|
||||||
|
return pgTableCreator((localName) => moduleTableName(moduleId, localName));
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,54 @@ import {
|
|||||||
type ModuleDatabaseError,
|
type ModuleDatabaseError,
|
||||||
type ModuleDatabaseParameter,
|
type ModuleDatabaseParameter,
|
||||||
type ModuleDatabaseQuery,
|
type ModuleDatabaseQuery,
|
||||||
|
type ModuleSchemaMetadataDefinition,
|
||||||
|
parseModuleSchemaMetadataDefinition,
|
||||||
} from "@ekklesia/contracts";
|
} from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@ekklesia/module-database`: everything a module needs for the Module Database ABI v1 (ADR 0050),
|
||||||
|
* with no ORM. Drizzle support lives in the `@ekklesia/module-database/drizzle` subpath, the only
|
||||||
|
* place that needs Drizzle (ADR 0053).
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
isModuleDatabaseError,
|
||||||
|
MODULE_DATABASE_ABI_VERSION,
|
||||||
|
MODULE_DATABASE_ERROR_CODES,
|
||||||
|
type ModuleDatabaseAbiVersion,
|
||||||
|
type ModuleDatabaseArrayRow,
|
||||||
|
ModuleDatabaseError,
|
||||||
|
type ModuleDatabaseErrorCode,
|
||||||
|
type ModuleDatabaseExecutor,
|
||||||
|
type ModuleDatabaseField,
|
||||||
|
type ModuleDatabaseIsolationLevel,
|
||||||
|
type ModuleDatabaseObjectRow,
|
||||||
|
type ModuleDatabaseParameter,
|
||||||
|
type ModuleDatabaseQuery,
|
||||||
|
type ModuleDatabaseQueryable,
|
||||||
|
type ModuleDatabaseQueryResult,
|
||||||
|
type ModuleDatabaseRowMode,
|
||||||
|
type ModuleDatabaseScalar,
|
||||||
|
type ModuleDatabaseTransaction,
|
||||||
|
type ModuleDatabaseTransactionOptions,
|
||||||
|
type ModuleDatabaseValue,
|
||||||
|
type ModuleSchemaForeignKeyMetadata,
|
||||||
|
type ModuleSchemaMetadataDefinition,
|
||||||
|
type ModuleSchemaTableMetadata,
|
||||||
|
moduleTableName,
|
||||||
|
moduleTablePrefix,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A module's plain schema metadata (ADR 0050): the tables it owns and their foreign keys, with no
|
||||||
|
* ORM object. Validated and frozen; a Drizzle module derives the same data with
|
||||||
|
* `moduleDrizzleSchemaMetadata` from `@ekklesia/module-database/drizzle`.
|
||||||
|
*/
|
||||||
|
export function defineModuleSchemaMetadata<const Definition extends ModuleSchemaMetadataDefinition>(
|
||||||
|
definition: Definition,
|
||||||
|
): Readonly<Definition> {
|
||||||
|
return parseModuleSchemaMetadataDefinition(definition) as Readonly<Definition>;
|
||||||
|
}
|
||||||
|
|
||||||
/** SQL text and values ready to pass directly to a Module Database ABI v1 executor. */
|
/** SQL text and values ready to pass directly to a Module Database ABI v1 executor. */
|
||||||
export type ModuleSqlQuery = Readonly<Required<Pick<ModuleDatabaseQuery, "text" | "values">>>;
|
export type ModuleSqlQuery = Readonly<Required<Pick<ModuleDatabaseQuery, "text" | "values">>>;
|
||||||
|
|
||||||
|
|||||||
+7
-10
@@ -1,20 +1,17 @@
|
|||||||
import { getTableConfig, integer } from "drizzle-orm/pg-core";
|
import { getTableConfig, integer } from "drizzle-orm/pg-core";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createModuleTable, defineModuleSchema } from "../src/index.js";
|
import { createModuleTable, moduleDrizzleSchemaMetadata } from "../src/drizzle.js";
|
||||||
|
|
||||||
describe("module schema authoring", () => {
|
describe("Drizzle module tables", () => {
|
||||||
it("creates namespaced PostgreSQL tables and a typed frozen definition", () => {
|
it("creates namespaced PostgreSQL tables that become plain schema metadata", () => {
|
||||||
const moduleTable = createModuleTable("care-team");
|
const moduleTable = createModuleTable("care-team");
|
||||||
const members = moduleTable("members", { id: integer().primaryKey() });
|
const members = moduleTable("members", { id: integer().primaryKey() });
|
||||||
const definition = defineModuleSchema({
|
|
||||||
moduleId: "care-team",
|
|
||||||
tables: { members },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getTableConfig(members).name).toBe("care_team_members");
|
expect(getTableConfig(members).name).toBe("care_team_members");
|
||||||
expect(definition.tables.members).toBe(members);
|
expect(moduleDrizzleSchemaMetadata("care-team", { members })).toEqual({
|
||||||
expect(Object.isFrozen(definition)).toBe(true);
|
moduleId: "care-team",
|
||||||
expect(Object.isFrozen(definition.tables)).toBe(true);
|
tables: [{ foreignKeys: [], name: "care_team_members" }],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid module and logical table names", () => {
|
it("rejects invalid module and logical table names", () => {
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Committed launcher, deliberately not generated: see ekklesia-pack-module.mjs for why. It defers to
|
||||||
|
// the compiled CLI at run time.
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const compiledCli = new URL("../dist/corresponding-source-cli.js", import.meta.url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await import(compiledCli.href);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ERR_MODULE_NOT_FOUND") {
|
||||||
|
process.stderr.write(
|
||||||
|
`@ekklesia/module-pack is not built yet (${fileURLToPath(compiledCli)}).\n` +
|
||||||
|
"Run `pnpm build` from the repository root first.\n",
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
# Rebuilding `@img/sharp-libvips-linux-x64` 1.3.4
|
||||||
|
|
||||||
|
This directory is the corresponding source for one redistributed native binary. These instructions
|
||||||
|
say how to rebuild it from the source here, and how to use a rebuilt or modified library in place of
|
||||||
|
the redistributed one. They are technical instructions. They are not a legal opinion, and they have
|
||||||
|
not been legally certified.
|
||||||
|
|
||||||
|
## What this corresponds to
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| Package | `@img/sharp-libvips-linux-x64` 1.3.4, from the npm registry |
|
||||||
|
| Declared licence | `LGPL-3.0-or-later` |
|
||||||
|
| Content digest | `ekklesia-content-v1:sha256:2927fe11493461fa34847191d0b9b1c6441007c3e99f306e5708cb07fb0a5e59` |
|
||||||
|
| Binary | `lib/libvips-cpp.so.8.18.7` (SONAME `libvips-cpp.so.8.18.7`) |
|
||||||
|
| Target | Linux, x86-64, glibc 2.28 or newer |
|
||||||
|
| Used by | `sharp` 0.35.5, through `@img/sharp-linux-x64` 0.35.5 |
|
||||||
|
|
||||||
|
`lib/libvips-cpp.so.8.18.7` is one shared object. libvips and the libraries it uses are built as
|
||||||
|
static libraries and linked into it: 27 C and C++ libraries, glib's bundled gvdb, librsvg with its
|
||||||
|
Rust crates, and the Rust standard library. `recipe.json` lists every component, its licence, its
|
||||||
|
source file and patches with their SHA-256, and its relationship to the binary. `PROVENANCE.md`
|
||||||
|
records how all of this was established, and `licenses/` holds the reviewed licence and notice
|
||||||
|
material.
|
||||||
|
|
||||||
|
## What is here
|
||||||
|
|
||||||
|
- `sources/sharp-libvips-1.3.4.tar.gz`: the build definitions that produced the binary. They are
|
||||||
|
`build.sh`, `build/posix.sh`, `platforms/linux-x64/` (a Dockerfile and its toolchain files), and
|
||||||
|
`versions.properties`.
|
||||||
|
- `sources/*.tar.*`: the source archive of every compiled library, exactly as `build/posix.sh`
|
||||||
|
downloads it.
|
||||||
|
- `patches/`: the five patches `build/posix.sh` applies: glib, mozjpeg, librsvg, libultrahdr, and
|
||||||
|
libvips.
|
||||||
|
- `sources/crates/`: all 353 Rust crates librsvg's resolved `Cargo.lock` records.
|
||||||
|
- 147 are compiled into the binary.
|
||||||
|
- 17 are needed only to build it: build-script dependencies, and one procedural macro that adds no
|
||||||
|
code.
|
||||||
|
- 189 serve librsvg's other workspace members, its tests, or other platforms. Cargo resolves the
|
||||||
|
whole workspace lockfile even when it builds one package, so it needs them present to build
|
||||||
|
offline. None of them is compiled into the binary.
|
||||||
|
|
||||||
|
`recipe.json` records each crate's role.
|
||||||
|
- `sources/rust/rust-src-nightly-2026-09-27.tar.xz`: the standard-library source of the Rust toolchain
|
||||||
|
that built librsvg, `rustc 1.101.0-nightly (75a75c3e0 2026-09-26)`.
|
||||||
|
- `build/`: the resolved librsvg `Cargo.lock`, and the two scripts below that point the unchanged build
|
||||||
|
logic at these files.
|
||||||
|
|
||||||
|
Every file's SHA-256 is in `recipe.json` and in the archive's top-level `corresponding-source.json`.
|
||||||
|
Check them before building.
|
||||||
|
|
||||||
|
## How the original was built
|
||||||
|
|
||||||
|
sharp-libvips builds the binary in a Rocky Linux 8 container, which pins glibc 2.28:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./build.sh linux-x64
|
||||||
|
# = docker build --pull -t vips-dev-linux-x64 platforms/linux-x64
|
||||||
|
# docker run --rm -v "$PWD:/packaging" vips-dev-linux-x64 sh -c /packaging/build/posix.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`platforms/linux-x64/Dockerfile` installs the toolchain: gcc-toolset-15, cmake, nasm, pkgconf, meson
|
||||||
|
and ninja (from pip), a nightly Rust toolchain through rustup, and cargo-c. `build/posix.sh` then
|
||||||
|
downloads each library's archive, applies the patches, builds every library as a static library into
|
||||||
|
`/target`, and links libvips-cpp. It packs the result as `sharp-libvips-linux-x64.tar.gz`, whose
|
||||||
|
`lib/libvips-cpp.so.8.18.7` is the file the npm package ships. Meson runs with
|
||||||
|
`wrap_mode = 'nofallback'` (`platforms/linux-x64/meson.ini`), so it downloads no subproject. The only
|
||||||
|
forced fallback, gvdb, builds from the copy inside glib's archive.
|
||||||
|
|
||||||
|
## Rebuilding from this source
|
||||||
|
|
||||||
|
You need a Linux x86-64 host with Docker. Building the container image needs network access to fetch
|
||||||
|
the toolchain (Rocky Linux packages, rustup, pip, and crates.io for cargo-c). Source for the library
|
||||||
|
itself comes only from this directory.
|
||||||
|
|
||||||
|
1. **Unpack the build definitions.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tar -xzf sources/sharp-libvips-1.3.4.tar.gz # creates sharp-libvips-1.3.4/
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Point the build at this source.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh build/prepare-offline.sh sharp-libvips-1.3.4
|
||||||
|
```
|
||||||
|
|
||||||
|
It copies the archives and patches into `sharp-libvips-1.3.4/offline/`. It unpacks the crates into
|
||||||
|
a vendored directory source; Cargo checks each against the lockfile's checksum. It then makes three
|
||||||
|
mechanical edits:
|
||||||
|
- in `build/posix.sh`, `CURL` becomes `offline/offline-curl.sh`. That script answers each URL the
|
||||||
|
script would download with its local copy, using `build/url-map.tsv`, and fails for any other URL;
|
||||||
|
- in `build/posix.sh`, `cargo update --workspace` becomes a copy of the resolved `Cargo.lock`. Cargo
|
||||||
|
is set offline and uses the vendored crates;
|
||||||
|
- in `platforms/linux-x64/Dockerfile`, the Rust toolchain is pinned to `nightly-2026-09-27`, the
|
||||||
|
toolchain whose `rustc` built the original. The upstream file installs whatever nightly is
|
||||||
|
current.
|
||||||
|
|
||||||
|
Nothing that is compiled changes: every patch is still applied by `build/posix.sh`, exactly as
|
||||||
|
upstream.
|
||||||
|
|
||||||
|
3. **Build.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd sharp-libvips-1.3.4
|
||||||
|
./build.sh linux-x64
|
||||||
|
```
|
||||||
|
|
||||||
|
The result is `sharp-libvips-1.3.4/sharp-libvips-linux-x64.tar.gz`. Its
|
||||||
|
`lib/libvips-cpp.so.8.18.7` is the rebuilt library. A full build takes from tens of minutes to an
|
||||||
|
hour, depending on the host.
|
||||||
|
|
||||||
|
**Modifying a component.** Before step 3, change the source the build uses. Then `build/posix.sh`
|
||||||
|
compiles your version in place of the original:
|
||||||
|
- for a C or C++ library, replace its archive in `offline/files/` under the same name, or add a patch
|
||||||
|
to the matching step of `build/posix.sh`;
|
||||||
|
- for a Rust crate, edit it under `offline/vendor/`. The `.cargo-checksum.json` files list no
|
||||||
|
per-file checksums, so an edited crate still builds.
|
||||||
|
|
||||||
|
The result is not byte-identical to the redistributed binary: a compiler toolchain, timestamps, and
|
||||||
|
paths that were not recorded can all differ. It is built from the same source, by the same
|
||||||
|
definitions, for the same target.
|
||||||
|
|
||||||
|
## Using a rebuilt or modified library
|
||||||
|
|
||||||
|
sharp's native addon, `@img/sharp-linux-x64/lib/sharp-linux-x64-0.35.5.node`, links
|
||||||
|
`libvips-cpp.so.8.18.7` dynamically. Its RPATH begins with `$ORIGIN/../../sharp-libvips-linux-x64/lib`.
|
||||||
|
In an installed or extracted Ekklesia Module Package, that resolves to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<module directory>/node_modules/@img/sharp-libvips-linux-x64/lib/libvips-cpp.so.8.18.7
|
||||||
|
```
|
||||||
|
|
||||||
|
To use your library, replace that file with your build's `lib/libvips-cpp.so.8.18.7`. Keep the file
|
||||||
|
name and SONAME, and keep libvips's C++ API compatible with libvips 8.18, which sharp 0.35.5 is
|
||||||
|
compiled against. Then restart the process that loads the module. No other file changes, and the
|
||||||
|
addon does not need rebuilding.
|
||||||
|
|
||||||
|
`ldd <module directory>/node_modules/@img/sharp-linux-x64/lib/sharp-linux-x64-0.35.5.node` shows which
|
||||||
|
`libvips-cpp.so.8.18.7` the addon resolves.
|
||||||
|
|
||||||
|
A module package's own `ekklesia.package.json` records the SHA-256 of each file it shipped, so a
|
||||||
|
modified tree is no longer the package Ekklesia verified at installation. Install it as your own
|
||||||
|
build.
|
||||||
Generated
Vendored
+3440
File diff suppressed because it is too large
Load Diff
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Stands in for curl in sharp-libvips's build/posix.sh, so the build reads only this artifact's
|
||||||
|
# sources. Given the URL posix.sh would download, it prints the recorded local copy, or with -O
|
||||||
|
# writes it to the current directory under the URL's file name. A URL with no recorded copy fails:
|
||||||
|
# the build never reaches the network for source.
|
||||||
|
set -eu
|
||||||
|
write=""
|
||||||
|
url=""
|
||||||
|
for argument in "$@"; do
|
||||||
|
case "$argument" in
|
||||||
|
-O) write=yes ;;
|
||||||
|
-*) ;;
|
||||||
|
*) url="$argument" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
file="$(awk -F '\t' -v url="$url" '$1 == url { print $2 }' /packaging/offline/url-map.tsv)"
|
||||||
|
if [ -z "$file" ]; then
|
||||||
|
echo "offline-curl: no recorded source for $url" >&2
|
||||||
|
exit 22
|
||||||
|
fi
|
||||||
|
if [ -n "$write" ]; then
|
||||||
|
cp "/packaging/offline/files/$file" "./${url##*/}"
|
||||||
|
else
|
||||||
|
cat "/packaging/offline/files/$file"
|
||||||
|
fi
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Prepares an extracted sharp-libvips v1.3.4 source tree to build lib/libvips-cpp.so.8.18.7 from this
|
||||||
|
# artifact's sources alone. BUILDING.md describes each step.
|
||||||
|
#
|
||||||
|
# sh build/prepare-offline.sh <extracted sharp-libvips-1.3.4 directory>
|
||||||
|
#
|
||||||
|
# Only the source acquisition changes; nothing that is compiled does:
|
||||||
|
# - every archive and patch build/posix.sh downloads is read from this artifact (offline-curl.sh);
|
||||||
|
# - librsvg's Rust crates come from this artifact as a vendored directory source, and its lockfile
|
||||||
|
# is the resolved one recorded here instead of a fresh `cargo update` against crates.io;
|
||||||
|
# - platforms/linux-x64/Dockerfile installs the exact nightly Rust toolchain the binary records.
|
||||||
|
set -eu
|
||||||
|
here="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
tree="$(cd "$1" && pwd)"
|
||||||
|
[ -f "$tree/build/posix.sh" ] && [ -f "$tree/platforms/linux-x64/Dockerfile" ] || {
|
||||||
|
echo "prepare-offline: $1 is not an extracted sharp-libvips source tree." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
offline="$tree/offline"
|
||||||
|
rm -rf "$offline"
|
||||||
|
mkdir -p "$offline/files" "$offline/vendor"
|
||||||
|
|
||||||
|
cp "$here"/sources/*.tar.* "$here"/patches/*.patch "$offline/files/"
|
||||||
|
cp "$tree/THIRD-PARTY-NOTICES.md" "$offline/files/THIRD-PARTY-NOTICES.md"
|
||||||
|
cp "$here/build/url-map.tsv" "$here/build/Cargo.lock" "$offline/"
|
||||||
|
cp "$here/build/offline-curl.sh" "$offline/offline-curl.sh"
|
||||||
|
chmod 755 "$offline/offline-curl.sh"
|
||||||
|
|
||||||
|
for crate in "$here"/sources/crates/*.crate; do
|
||||||
|
tar -xzf "$crate" -C "$offline/vendor"
|
||||||
|
name="$(basename "$crate" .crate)"
|
||||||
|
checksum="$(sha256sum "$crate" | cut -d ' ' -f 1)"
|
||||||
|
printf '{"files":{},"package":"%s"}\n' "$checksum" > "$offline/vendor/$name/.cargo-checksum.json"
|
||||||
|
done
|
||||||
|
cat > "$offline/cargo-config.toml" <<'CONFIG'
|
||||||
|
[source.crates-io]
|
||||||
|
replace-with = "vendored-sources"
|
||||||
|
|
||||||
|
[source.vendored-sources]
|
||||||
|
directory = "/packaging/offline/vendor"
|
||||||
|
|
||||||
|
[net]
|
||||||
|
offline = true
|
||||||
|
CONFIG
|
||||||
|
|
||||||
|
posix="$tree/build/posix.sh"
|
||||||
|
grep -q '^CURL="curl ' "$posix" && grep -q '^cargo update --workspace$' "$posix" || {
|
||||||
|
echo "prepare-offline: build/posix.sh is not the v1.3.4 script this artifact was prepared for." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
sed -i \
|
||||||
|
-e 's|^CURL="curl .*"$|CURL="/packaging/offline/offline-curl.sh"\nexport CARGO_NET_OFFLINE=true\nmkdir -p "${CARGO_HOME:-$HOME/.cargo}" \&\& cp /packaging/offline/cargo-config.toml "${CARGO_HOME:-$HOME/.cargo}/config.toml"|' \
|
||||||
|
-e 's|^cargo update --workspace$|cp /packaging/offline/Cargo.lock Cargo.lock|' \
|
||||||
|
"$posix"
|
||||||
|
dockerfile="$tree/platforms/linux-x64/Dockerfile"
|
||||||
|
grep -q -- '--default-toolchain nightly ' "$dockerfile" || {
|
||||||
|
echo "prepare-offline: platforms/linux-x64/Dockerfile is not the v1.3.4 one." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
sed -i 's|--default-toolchain nightly |--default-toolchain nightly-2026-09-27 |' "$dockerfile"
|
||||||
|
echo "prepare-offline: $tree builds from this artifact's sources."
|
||||||
Vendored
+33
@@ -0,0 +1,33 @@
|
|||||||
|
https://cairographics.org/releases/cairo-1.18.6.tar.xz cairo-1.18.6.tar.xz
|
||||||
|
https://cairographics.org/releases/pixman-0.46.4.tar.gz pixman-0.46.4.tar.gz
|
||||||
|
https://download.gnome.org/sources/glib/2.90/glib-2.90.0.tar.xz glib-2.90.0.tar.xz
|
||||||
|
https://download.gnome.org/sources/librsvg/2.63/librsvg-2.63.2.tar.xz librsvg-2.63.2.tar.xz
|
||||||
|
https://download.gnome.org/sources/libxml2/2.15/libxml2-2.15.4.tar.xz libxml2-2.15.4.tar.xz
|
||||||
|
https://download.gnome.org/sources/pango/1.58/pango-1.58.2.tar.xz pango-1.58.2.tar.xz
|
||||||
|
https://gist.github.com/kleisauke/284d685efa00908da99ea6afbaaf39ae/raw/bdad5489a61c217850631571caf57f5db6ea8b2c/glib-without-gregex.patch glib-without-gregex.patch
|
||||||
|
https://gist.githubusercontent.com/lovell/313a6901e9db1bf285f2a1f1180499e4/raw/3988223c7dfa4d22745d9392034b0117abef1446/libvips-cpp-soversion.patch libvips-cpp-soversion.patch
|
||||||
|
https://github.com/GNOME/librsvg/commit/9106011db93d701728ac2c9da50c9ab2c1bb5dc6.patch librsvg-9106011db93d701728ac2c9da50c9ab2c1bb5dc6.patch
|
||||||
|
https://github.com/dloebl/cgif/archive/v0.5.4.tar.gz cgif-0.5.4.tar.gz
|
||||||
|
https://github.com/freetype/freetype/archive/VER-2-14-3.tar.gz freetype-2.14.3.tar.gz
|
||||||
|
https://github.com/fribidi/fribidi/releases/download/v1.0.17/fribidi-1.0.17.tar.xz fribidi-1.0.17.tar.xz
|
||||||
|
https://github.com/google/highway/archive/1.4.0.tar.gz highway-1.4.0.tar.gz
|
||||||
|
https://github.com/google/libultrahdr/archive/v2.0.2.tar.gz libultrahdr-2.0.2.tar.gz
|
||||||
|
https://github.com/harfbuzz/harfbuzz/archive/14.5.0.tar.gz harfbuzz-14.5.0.tar.gz
|
||||||
|
https://github.com/libarchive/libarchive/releases/download/v3.8.9/libarchive-3.8.9.tar.xz libarchive-3.8.9.tar.xz
|
||||||
|
https://github.com/libexif/libexif/releases/download/v0.6.26/libexif-0.6.26.tar.xz libexif-0.6.26.tar.xz
|
||||||
|
https://github.com/libexpat/libexpat/releases/download/R_2_8_5/expat-2.8.5.tar.xz expat-2.8.5.tar.xz
|
||||||
|
https://github.com/libffi/libffi/releases/download/v3.8.0/libffi-3.8.0.tar.gz libffi-3.8.0.tar.gz
|
||||||
|
https://github.com/libvips/libvips/releases/download/v8.18.7/vips-8.18.7.tar.xz vips-8.18.7.tar.xz
|
||||||
|
https://github.com/lovell/libimagequant/archive/v2.4.1.tar.gz libimagequant-2.4.1.tar.gz
|
||||||
|
https://github.com/mm2/Little-CMS/releases/download/lcms2.19.1/lcms2-2.19.1.tar.gz lcms2-2.19.1.tar.gz
|
||||||
|
https://github.com/mozilla/mozjpeg/archive/0826579.tar.gz mozjpeg-0826579.tar.gz
|
||||||
|
https://github.com/mozilla/mozjpeg/commit/f90668e0e4fb79c81e1f24a0ccc0e2090af761bf.patch mozjpeg-f90668e0e4fb79c81e1f24a0ccc0e2090af761bf.patch
|
||||||
|
https://github.com/pnggroup/libpng/archive/v1.6.58.tar.gz libpng-1.6.58.tar.gz
|
||||||
|
https://github.com/strukturag/libheif/releases/download/v1.23.5/libheif-1.23.5.tar.gz libheif-1.23.5.tar.gz
|
||||||
|
https://github.com/zlib-ng/zlib-ng/archive/2.3.3.tar.gz zlib-ng-2.3.3.tar.gz
|
||||||
|
https://gitlab.com/libtiff/libtiff/-/archive/v4.7.2/libtiff-v4.7.2.tar.gz libtiff-v4.7.2.tar.gz
|
||||||
|
https://gitlab.freedesktop.org/fontconfig/fontconfig/-/archive/2.18.3/fontconfig-2.18.3.tar.gz fontconfig-2.18.3.tar.gz
|
||||||
|
https://patch-diff.githubusercontent.com/raw/google/libultrahdr/pull/383.patch libultrahdr-pull-383.patch
|
||||||
|
https://raw.githubusercontent.com/lovell/sharp-libvips/main/THIRD-PARTY-NOTICES.md THIRD-PARTY-NOTICES.md
|
||||||
|
https://storage.googleapis.com/aom-releases/libaom-3.15.1.tar.gz libaom-3.15.1.tar.gz
|
||||||
|
https://storage.googleapis.com/downloads.webmproject.org/releases/webp/libwebp-1.6.0.tar.gz libwebp-1.6.0.tar.gz
|
||||||
|
+7538
File diff suppressed because it is too large
Load Diff
+160
@@ -0,0 +1,160 @@
|
|||||||
|
# Provenance: license supplement for `@img/[email protected]`
|
||||||
|
|
||||||
|
This is the human-readable record of how the reviewed material in `supplement.json` was assembled
|
||||||
|
(ADR 0052). The packager does not read this file. It reads only `supplement.json` and the texts that
|
||||||
|
file lists, each one named by its SHA-256.
|
||||||
|
|
||||||
|
Review of this supplement is project and code review of a build input. It checks that the material
|
||||||
|
matches the sources named below. **It is not legal review.** It does not establish that every
|
||||||
|
redistribution obligation for this binary is met. In particular, it does not decide whether or how
|
||||||
|
the source-availability obligations of its LGPL and MPL components are met, which remains open.
|
||||||
|
|
||||||
|
## The package
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Package | `@img/sharp-libvips-linux-x64` 1.3.3, from the npm registry |
|
||||||
|
| npm integrity | `sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==` |
|
||||||
|
| Declared licence | `LGPL-3.0-or-later` |
|
||||||
|
| Content digest | `ekklesia-content-v1:sha256:50647b0b1e0b99943f24e4a1580650c0d8fe7744779b0f16271dcd2d59a7678c` |
|
||||||
|
|
||||||
|
The content digest is over the six files the packager copies: `README.md`, `package.json`,
|
||||||
|
`versions.json`, `lib/index.js`, `lib/glib-2.0/include/glibconfig.h`, and
|
||||||
|
`lib/libvips-cpp.so.8.18.6`. It was computed twice and gave the same result both times: once from
|
||||||
|
the copy pnpm installed, and once from the registry tarball extracted directly.
|
||||||
|
|
||||||
|
The package ships no licence text and no notices. Its README lists 29 libraries and the licence each
|
||||||
|
is used under, mostly as links. `lib/libvips-cpp.so.8.18.6` is one shared object with those
|
||||||
|
libraries built into it.
|
||||||
|
|
||||||
|
## What was built into the binary
|
||||||
|
|
||||||
|
The build is sharp-libvips v1.3.3:
|
||||||
|
- source: `https://github.com/lovell/sharp-libvips/archive/refs/tags/v1.3.3.tar.gz`;
|
||||||
|
- sha256: `544ebd8e34be4baf751cc26c8691a46eddbb56183ead1b20ca5b737e93482d86`.
|
||||||
|
|
||||||
|
The build is defined by `build/posix.sh`, `platforms/linux-x64/Dockerfile`, and
|
||||||
|
`versions.properties`. Its `versions.properties` matches the package's own `versions.json`.
|
||||||
|
|
||||||
|
- **C and C++ libraries.** 27 libraries are built from the upstream archives below, at exactly the
|
||||||
|
URLs `build/posix.sh` fetches.
|
||||||
|
- `proxy-libintl` is listed in `versions.json` but is built only for musl and macOS, not for
|
||||||
|
linux-x64 (glibc). It is therefore not a component of this binary.
|
||||||
|
- Highway is built, because `WITHOUT_HIGHWAY` is unset for linux-x64.
|
||||||
|
- **gvdb.** glib is configured with `--force-fallback-for=gvdb`, so the gvdb subproject shipped
|
||||||
|
inside glib's archive is built in. Its LGPL-2.1-or-later files are taken from that archive.
|
||||||
|
- **librsvg's Rust crates.**
|
||||||
|
- **What the build does.** It edits `rsvg/Cargo.toml` and `librsvg-c/Cargo.toml`, dropping the
|
||||||
|
`gif` and `webp` image features and the `pdf` and `ps` cairo features. It runs
|
||||||
|
`cargo update --workspace`, then builds `librsvg-c` with
|
||||||
|
`cargo cbuild --locked --release --target x86_64-unknown-linux-gnu`, with neither the AVIF nor
|
||||||
|
the pixbuf feature (no dav1d, pixbuf disabled).
|
||||||
|
- **How it was reproduced.** In `rust:1-slim` (cargo 1.99.0), with the same edits, librsvg
|
||||||
|
2.62.91's shipped `Cargo.lock` was updated with `cargo update --workspace`.
|
||||||
|
- That removed only the crates the edits drop (gif, image-webp, color_quant, and their
|
||||||
|
dependencies). It changed no other version, so the compiled set follows from the shipped
|
||||||
|
lockfile.
|
||||||
|
- The resulting `Cargo.lock` has sha256
|
||||||
|
`5ea585dbbcce18a8e74e094a383c4ca2f133102464bd3c9169636a485bf362c2`.
|
||||||
|
- **The crate set.** `cargo tree -p librsvg-c --features capi --target x86_64-unknown-linux-gnu -e
|
||||||
|
normal` lists the crates. Procedural-macro crates are included, which is conservative.
|
||||||
|
- **Where the files come from.** Each crate's licence files are taken from its published `.crate`
|
||||||
|
file. The crate's sha256 is the `checksum` from that `Cargo.lock`.
|
||||||
|
- **Exceptions.**
|
||||||
|
- `selectors` 0.40.0 (MPL-2.0) ships no licence file, so it carries the standard MPL-2.0 text.
|
||||||
|
- `mutants` 0.0.4 (MIT) ships no licence file. It is a no-op attribute macro that runs only at
|
||||||
|
compile time and contributes no code to the binary, so it is not a component.
|
||||||
|
- **The Rust standard library.**
|
||||||
|
- The binary records `/rustc/787af2b8c80638c51a4fc8e44f84e6891f243ec7/…` paths. That is a nightly
|
||||||
|
rustc commit, merged on 2026-08-25, two days before the package was built.
|
||||||
|
- Its std lockfile pins the five crate versions that are visible in the binary's strings:
|
||||||
|
addr2line 0.27.1, gimli 0.34.0, hashbrown 0.17.1, miniz_oxide 0.9.1, and rustc-demangle 0.1.28.
|
||||||
|
- **Where the files come from.** `COPYRIGHT`, `LICENSE-APACHE`, `LICENSE-MIT`, and
|
||||||
|
`library/compiler-builtins/LICENSE.txt` are taken from that commit.
|
||||||
|
- **The crates covered.** These are std's crates.io dependencies for x86_64-unknown-linux-gnu, read
|
||||||
|
from `library/std/Cargo.toml`'s target sections at that commit:
|
||||||
|
- addr2line, adler2, cfg-if, foldhash, gimli, hashbrown, libc, memchr, miniz_oxide, object, and
|
||||||
|
rustc-demangle;
|
||||||
|
- each with its `.crate` file's licence files.
|
||||||
|
- **Not covered**, because it is not linked into this target:
|
||||||
|
- build-only: cc, shlex, find-msvc-tools;
|
||||||
|
- test-only: rand, rand_core, rand_xorshift;
|
||||||
|
- other platforms: dlmalloc, fortanix-sgx-abi, hermit-abi, moto-rt, r-efi, r-efi-alloc, unwinding,
|
||||||
|
vex-sdk, wasip1, wasip2, wasip3, wit-bindgen.
|
||||||
|
- **Not redistributed here.** The binary dynamically links the host's `libstdc++.so.6`,
|
||||||
|
`libgcc_s.so.1`, and glibc libraries. They come from the system that loads the package, so they
|
||||||
|
are not part of this package.
|
||||||
|
|
||||||
|
## Standard texts added
|
||||||
|
|
||||||
|
| Text | Source | sha256 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GNU LGPL 3.0, for the package's declared `LGPL-3.0-or-later` | https://www.gnu.org/licenses/lgpl-3.0.txt | `e3a994d82e644b03a792a930f574002658412f62407f5fee083f2555c5f23118` |
|
||||||
|
| GNU GPL 3.0, which the LGPL 3.0 incorporates | https://www.gnu.org/licenses/gpl-3.0.txt | `3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986` |
|
||||||
|
| MPL 2.0 | https://www.mozilla.org/MPL/2.0/ (text file `index.f75d2927d3c1.txt`) | `3f3d9e0024b1921b067d6f7f88deb4a60cbe7a78e76c64e3f1d7fc3b779b9d04` |
|
||||||
|
|
||||||
|
The MPL 2.0 text is carried for two components:
|
||||||
|
- **cairo.** cairo ships the LGPL-2.1 and MPL-1.1 texts. sharp-libvips states that cairo is used
|
||||||
|
under MPL 2.0.
|
||||||
|
- **selectors**, which ships no licence file.
|
||||||
|
|
||||||
|
Licence names in `supplement.json` are as sharp-libvips's `THIRD-PARTY-NOTICES.md` states them for
|
||||||
|
each library, and as each crate's `Cargo.toml` declares it. They are recorded, not determined.
|
||||||
|
|
||||||
|
## Upstream archives (C and C++ libraries)
|
||||||
|
|
||||||
|
Each archive was downloaded from exactly the URL `build/posix.sh` uses for the versions in
|
||||||
|
`versions.properties`:
|
||||||
|
|
||||||
|
| Archive | sha256 |
|
||||||
|
| --- | --- |
|
||||||
|
| `cairo-1.18.4.tar.xz` | `445ed8208a6e4823de1226a74ca319d3600e83f6369f99b14265006599c32ccb` |
|
||||||
|
| `cgif-0.5.3.tar.gz` | `dcc7731e974ee77db75df26c99aca4d95f11ca2d267d870d42bce1e0d1e1e75f` |
|
||||||
|
| `expat-2.8.3.tar.xz` | `f6256df90c906773d344da084402b7d3e4f22ed41b1a59c989098a83d3ea0c85` |
|
||||||
|
| `fontconfig-2.18.3.tar.gz` | `9ae01e1d53acdef56010c5451cd34aa41d325b2faccd8606448d8fa01b2496b3` |
|
||||||
|
| `freetype-2.14.3.tar.gz` | `dc49de6b01a266eef4876a4dd34d9842c475d3e28ff2eff63bd2fb760ab56261` |
|
||||||
|
| `fribidi-1.0.16.tar.xz` | `1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c` |
|
||||||
|
| `glib-2.89.4.tar.xz` | `1cdbb799f558832e6f14b8337b5fd599c6918ab144977b55e05e00a5e2e84a2c` |
|
||||||
|
| `harfbuzz-14.3.1.tar.gz` | `05e602daf0846d7497d842807766ea32ca270f3e2c9284ce03811a2a1ed669c9` |
|
||||||
|
| `highway-1.4.0.tar.gz` | `e72241ac9524bb653ae52ced768b508045d4438726a303f10181a38f764a453c` |
|
||||||
|
| `lcms2-2.19.1.tar.gz` | `bfc54f7bab59fbc921012014a8032e4cba4abd46db47d46b76416a8c0b2815c8` |
|
||||||
|
| `libaom-3.15.0.tar.gz` | `ea08c38ecc078bc85bb1b691020e52b06250f1a81fe7ca5b624629225081af96` |
|
||||||
|
| `libarchive-3.8.9.tar.xz` | `888c934f9d95648ecb9163dc8e23ab80a476ecb81a8f1154704a227b5b676dde` |
|
||||||
|
| `libexif-0.6.26.tar.xz` | `4a055ed6575e61ca46c3172be3c753cc16c9becd0f99ec71d58dd0e471476c0c` |
|
||||||
|
| `libffi-3.8.0.tar.gz` | `7da3e2d9a171eb0a038f592ecad3ff2bb2550f3496d87b3b29ad0cf4430c0db4` |
|
||||||
|
| `libheif-1.23.2.tar.gz` | `8bd5d41d19dc84536d118b04774709f244df6104ef66d623dad5fa4650143405` |
|
||||||
|
| `libimagequant-2.4.1.tar.gz` | `47d2a84b7b1052975c9d50a3d4e3cacbf57b43d84a4c3131210848ead9964dfb` |
|
||||||
|
| `libpng-1.6.58.tar.gz` | `a9d4df463d36a6e5f9c29bd6f4967312d17e996c1854f3511f833924eb1993cf` |
|
||||||
|
| `librsvg-2.62.91.tar.xz` | `6caeae129d40dd88f8ec49436fd89bfe4ada716a125187a7fa0def8fcaa0b250` |
|
||||||
|
| `libtiff-v4.7.2.tar.gz` | `62b15f44c10106065211efa22a2dc77937bd97ee1f6e3afd2c9659010de112eb` |
|
||||||
|
| `libultrahdr-2.0.2.tar.gz` | `aa8d193bb887c348c419780511dd03b374f4e07af8812b6d3f80c8537cf1ef2c` |
|
||||||
|
| `libwebp-1.6.0.tar.gz` | `e4ab7009bf0629fd11982d4c2aa83964cf244cffba7347ecd39019a9e38c4564` |
|
||||||
|
| `libxml2-2.15.3.tar.xz` | `78262a6e7ac170d6528ebfe2efccdf220191a5af6a6cd61ea4a9a9a5042c7a07` |
|
||||||
|
| `mozjpeg-0826579.tar.gz` | `b680167bd5d9afebdd576a12f8c49d2c9066a3b92ef96a2b2c5f7829b8600fe5` |
|
||||||
|
| `pango-1.58.2.tar.xz` | `342385b6ca3b7c73455d7c80a13b7dbe4489e00bc3bd4c5bd6ed4dce421e374a` |
|
||||||
|
| `pixman-0.46.4.tar.gz` | `d09c44ebc3bd5bee7021c79f922fe8fb2fb57f7320f55e97ff9914d2346a591c` |
|
||||||
|
| `vips-8.18.6.tar.xz` | `3c41e1d5458081bfa4a5bc54e116c46259c75c6760a18027764555632b9dda3e` |
|
||||||
|
| `zlib-ng-2.3.3.tar.gz` | `f9c65aa9c852eb8255b636fd9f07ce1c406f061ec19a2e7d508b318ca0c907d1` |
|
||||||
|
|
||||||
|
## Rust standard library sources
|
||||||
|
|
||||||
|
| File at rustc `787af2b8c80638c51a4fc8e44f84e6891f243ec7` | sha256 |
|
||||||
|
| --- | --- |
|
||||||
|
| `COPYRIGHT` | `172020dbfd5b53a226dfde77616190a48dcff519b0bc0e6deb91a8450782c4af` |
|
||||||
|
| `LICENSE-APACHE` | `62c7a1e35f56406896d7aa7ca52d0cc0d272ac022b5d2796e7d6905db8a3636a` |
|
||||||
|
| `LICENSE-MIT` | `b71bd43a069ca0641a9ecfe585ca7b3c53b5cc1608f8b68321168698e28b5ea1` |
|
||||||
|
| `library/compiler-builtins/LICENSE.txt` | `ab6eec6caf0fa5775e411c7a8bc6a45c4ef2956b0980b157ab74fc5cd62a928b` |
|
||||||
|
| `library/Cargo.lock` | `fc922f114685e5cbdb3dce572cb7e3b7023a45b57e3a436e23abf35d240c5d9f` |
|
||||||
|
|
||||||
|
Each crate's `.crate` source and sha256 are recorded in that crate's `source` in `supplement.json`.
|
||||||
|
|
||||||
|
## Open questions this supplement does not answer
|
||||||
|
|
||||||
|
- **Source availability.** The LGPL components (libvips, glib, gvdb, pango, fribidi, libexif,
|
||||||
|
libheif, librsvg, cairo under the LGPL alternative) and the MPL components (cairo and the MPL Rust
|
||||||
|
crates) carry source-availability obligations. Whether and how those are met when this binary is
|
||||||
|
redistributed has not been determined. Recording the upstream source locations above does not
|
||||||
|
determine it.
|
||||||
|
- **Upstream accuracy.** The licence each library is used under is sharp-libvips's statement. For
|
||||||
|
example, it states that the LGPL libraries are used under LGPLv3 through the "any later version"
|
||||||
|
clause, and that cairo is used under MPL 2.0. These statements are recorded, not verified.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user