Commit Graph
116 Commits
Author SHA1 Message Date
jschoubben 19666ff054 anthropic-manager: seal over audited tweetnacl-sealedbox-js, not a hand-transcribed NaCl
The manager reseals a rotated refresh token to the node key with crypto_box_seal. That
seal was a full inline transcription of TweetNaCl's XSalsa20-Poly1305 and blakejs' BLAKE2b
(dependency-free, ~440 lines). Replace the internals with the audited
tweetnacl-sealedbox-js library — the same crypto_box_seal, on the same tweetnacl and blakejs
the mesh used to validate the seal during Phase C.

The exported API is unchanged: seal(value, recipientPublicB64) -> base64. The wire format is
unchanged too — ephemeralPub(32) followed by the box, nonce = blake2b(ephemeralPub +
recipientPub, 24) — so the host's Go box.OpenAnonymous still opens it. The mesh-control
cross-check fixture is regenerated from this seal().

The library and tweetnacl are added to the module's package.json dependencies so the runtime
image bundles them (blakejs arrives transitively). A local ambient .d.ts types the untyped
CJS bundle; it is imported as a default import because Node's ESM loader cannot see a UMD
bundle's named exports.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-07 02:17:37 +02:00
jschoubben 4c98bee043 anthropic-manager: seal the refresh token to the node key, do no crypto to open
The manager module drops its bespoke ECIES at-rest envelope and the node-private-key mount.
A module is never given a node's private key, so it cannot open an envelope -- the refresh
token is now delivered to it as cleartext by the host, unsealed from an ordinary sealed box.

  - sealedbox.ts: a dependency-free NaCl crypto_box_seal (node:crypto for X25519, transcribed
    XSalsa20-Poly1305 and BLAKE2b-24), byte-compatible with Go's box.SealAnonymous. It SEALS
    only -- opening is the host's job. Proven by a cross-language test in mesh-control.
  - adopt: reads the node's PUBLIC key from the delivered bound facts and seals the operator's
    refresh token to it, handing out only the box.
  - refresh: reads the refresh token as cleartext the host mounted, calls the vendor, re-seals
    a rotated token to the node's public key, submits only { access token, box }.
  - module.json: a model-access holder now -- binds the facts, binds the refresh token as a
    sealed secret; no keys dir, no MESH_NODE_SEALING_* mount.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-07 01:55:19 +02:00
jschoubben c206e2e11e anthropic model-access modules: manager (refreshable-grant) and consumer
Phase C of vendor-agnostic model-access (ADR 0050/0054). Two TypeScript
runtime modules:

- anthropic-manager: the refresh token is sealed at rest to the manager
  node's own key (atrest.ts, envelope encryption over X25519) and opened
  ONLY on the manager node. adopt seals the first envelope; refresh opens
  it, calls the Anthropic OAuth token endpoint, re-seals a rotated refresh
  token, and hands the control plane only the access token plus the opaque
  envelope. Also polls licence-grain usage (ADR 0054).
- anthropic-consumer: writes the delivered access token to
  ~/.claude/.credentials.json, access-token-only, atomically (the refresh
  token is never delivered); reports session-grain usage from the CLI
  transcripts; a fail-closed identity guard (expected-uuid plumbing is a
  flagged TODO).

Both run as scheduled containers (ADR 0053). Pure logic covered by
node --test fixtures (at-rest round-trip, credential strip, transcript
sum, refresh merge).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-07 01:00:12 +02:00
jschoubben cd46d53464 Merge pull request 'lavinmq: a user-facing AMQP provider (vhost-per-login)' (#15) from feat/convert-lavinmq into main 2026-09-06 23:21:27 +02:00
jschoubben 97707480d6 Add lavinmq amqp provider and amqp-ping consumer
lavinmq becomes a provider of a user-facing amqp interface: a consumer
that requires a message queue is given its OWN broker — a scoped vhost
and user on a lavinmq provider — not an account on the mesh's own
control-plane broker (ADR 0048). Vhost-per-login is the isolation model,
the exact analog of postgres's database-per-login: the provider names a
vhost after the consumer's login and a user with full rights on that
vhost and none elsewhere, so a login is a broker the consumer alone can
reach.

The provider drives lavinmq through its HTTP management API (client.ts,
the module's one impure seam), with a run-once bootstrap that computes
the RabbitMQ-compatible password hash lavinmq's config wants from the
plain admin secret the mesh mints — the value no ${secret:...}
placeholder can produce and the reason the bootstrap exists (ADR 0052).
serves.amqp carries the port so consumers reference ${bound:amqp:port}.

amqp-ping is a demo consumer: it contributes nothing (the vhost is the
login), reads its grant from an env-file the mesh fills, and uses
${bound:amqp:as} for BOTH its username and its vhost — the db-name
lesson applied to AMQP. It speaks AMQP 0-9-1 over a raw socket with no
npm dependency (the way redis speaks RESP) and round-trips one message.
It carries a slug so its identity fits the 20-char backend bound
(ADR 0049).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 15:50:19 +02:00
jschoubben 090a839d42 Merge pull request 'route-proxy: the native ingress module (drop traefik)' (#14) from feat/route-proxy-module into main 2026-09-06 15:17:21 +02:00
jschoubben 89254ef2c8 hello-web: add slug so its identity fits the 20-char backend bound (ADR 0049)
mesh_anchor_hello_web is 21 chars, one over the S3 access-key bound; slug 'hello' brings it to 17.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 15:16:56 +02:00
jschoubben 36640f68e6 Add route-proxy provider and hello-web consumer modules
route-proxy is the shipping form of the reference reverse proxy (novox/hq
ADR 0007, 08-connectivity section 3): it provides route, is given every
consumer as the file at receives.route, and forwards by the Host header. It
ships the Go proxy from mesh-control/examples/route-proxy via a multi-stage
Dockerfile; no broker, own-secret or provisioner, since it only reads the file
the mesh writes.

ACME_DIRECTORY defaults to Let's Encrypt staging and is overridable per node to
production, so there is no hardcoded production default -- resolving novox/hq
04-ISSUES/004. hello-web is a minimal consumer that requires route and
contributes name+port, to exercise the grant.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 15:07:16 +02:00
jschoubben 9dbbfbb5b4 Merge pull request 'fix: postgres consumers connect to the mesh-named db (the login), not a hardcoded name' (#13) from fix/consumer-db-name into main 2026-09-06 13:47:34 +02:00
jschoubben d301e05794 fix: postgres consumers connect to the db the mesh named (the login), not a hardcoded app name
The postgres provisioner creates each consumer a database named after the login the mesh
minted (mesh_<node>_<module>), per ADR 0048 -- 'a same-named database under exactly that
login'. But baserow, letta, umami, gitea, keycloak and nextcloud each hardcoded their app db
name (DATABASE_NAME=baserow, /letta, /umami, NAME=gitea, /keycloak, POSTGRES_DB=nextcloud),
so the service connected to a database that does not exist ('database letta does not exist').
Each now uses ${bound:postgres-database:as} as the db name -- the login, which is also the db
name -- matching the working meshboard pattern and the provisioner's actual behaviour.

Found by the two-node DB-consumer lab install (mesh-lab assigned-two-node-db); baserow is
proven connecting and running there. The S3 bucket name is the same class of assumption and is
a separate follow-up (s3 identity has its own length bound).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 13:47:16 +02:00
jschoubben dc12df18b4 Merge pull request 'fix: DB/cache/store providers must serve their port (migration blocker)' (#12) from fix/db-provider-serves-port into main 2026-09-06 03:31:50 +02:00
jschoubben 1f0c3ac69e fix: db/cache/store providers must serve their port; drop baserow's empty redis contribution
The breadth install (catalogue-broad) surfaced two latent resolution bugs that
single-module compile checks never caught (compile != resolve):

1. postgres/redis/mongodb/minio served no "port", yet seven consumers
   (baserow, letta, invoicing, gitea, umami, keycloak, nextcloud) reference
   ${bound:<provision>:port}. A binding auto-carries at/from/as; the port is the
   provider's half of the answer and must be declared in `serves` (manifest.go:
   "Serves is what a consumer needs to know ... a port, a path, a realm"). Added
   port to each: postgres 5432, redis 6379, mongodb 27017, minio 9000. Without it
   no DB consumer could resolve, let alone deploy.

2. baserow declared an empty contribution `contributes: {"redis-cache": {}}`.
   redis's serves spec for the cache is empty (a cache takes no per-consumer
   payload), so a consumer only `requires` it; an empty contribution is refused.
   Dropped it (requires/binds/secrets unchanged).

Found by mesh-lab catalogue-broad; the same fixes are mirrored in that bed.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 02:38:22 +02:00
jschoubben dba4554002 Merge pull request 'Convert confluence + jira on the tools-only template' (#11) from feat/convert-atlassian into main 2026-09-06 01:54:51 +02:00
jschoubben db8b60f4e4 Convert confluence and jira into nox catalog modules
Port the HAL confluence and jira integrations to the tools-only,
outbound-only external-SaaS pattern proven by the merged gitlab module:
runtime-only containers (container-runtime capability), own-secret token +
broker, a settings-managed config.json for public config, and a
per-module runtime image.

Each module carries its own Atlassian API client and tools (ADR 0039),
translated from HAL's @hal/sdk zod-schema/MCP-content shape into
mesh-sdk's input/run-returns-data shape. Public config (ATLASSIAN_URL,
ATLASSIAN_EMAIL) lives in config.json; the API token is the one
own-secret. Clients are built lazily and never throw at registration, so
each runtime serves its full tool surface with no credentials (the
Servarr lesson) — confluence serves 3 tools, jira serves 8.

jira's periodic ticket-poller (update-tickets.service/.timer) is NOT
ported: the mesh has no scheduled-task primitive yet (a pending
decision). Only jira's tools are ported; a top-of-file note records the
deferral.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 01:49:22 +02:00
jschoubben 2c3f241010 Merge pull request 'Convert gitlab — the tools-only external-SaaS exemplar (23 tools, lab-proven)' (#10) from feat/convert-gitlab into main 2026-09-06 01:40:31 +02:00
jschoubben 7eb156a82a Convert gitlab into a tools-only mesh catalog module
Port the HAL gitlab module (whose tools lived in @hal/sdk) into a
self-contained mesh-catalog module modelled on cloudflare-dns: the GitLab
API client and all its tools live in the module (ADR 0039), served through
mesh-sdk's registerModuleTools harness.

Tools-only, outbound-only external-SaaS shape: a runtime-only container on
network:host, no service, no listener, no provisioner. The token is an
own-secret; GITLAB_URL is a public setting in a merge:json config file.

The client is built lazily and never throws at registration, so the runtime
comes up and serves all 23 tools even with no valid token (the Servarr
lesson) — it only fails when a tool is actually invoked unconfigured.

Ported 23 tools: projects (list, get), merge requests (list, get, create,
approve, add note), pipelines (list, get, retry, cancel, list jobs, job log),
and project + group CI/CD variables (list, get, create, update, delete each).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 01:33:43 +02:00
jschoubben 09b140fe86 Merge pull request 'Convert baserow, letta, invoicing (DB/S3 consumers)' (#9) from feat/land-db-consumers into main 2026-09-06 01:14:30 +02:00
jschoubben 8b82e47889 invoicing: point app/api at the real registry host
The conversion left the image host as a placeholder (registry-api.example).
Point both containers at the actual registry the built app/api images live in
(novox/invoicing-{app,api}, tags 51-55 + latest present). Tracks :latest to
match the app's continuous-deploy model; pin by digest later if reproducibility
of a specific build is wanted.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 01:14:08 +02:00
jschoubben 515b1e79c7 Convert baserow, letta, invoicing to mesh catalog DB consumers
Mirror the postgres-consumer shape (keycloak/nextcloud): requires the
backing service(s), binds/secrets for the delivered credential, and a
server container that reads it from an interpolated env file.

- baserow: consumes postgres-database + redis-cache; tooled (dormant
  until an account is configured, like gitea's token).
- letta: consumes postgres-database; tooled, live via a mesh-minted
  server-password injected into both server and runtime.
- invoicing: consumes mongodb-database + s3-bucket; plain two-container
  service (app + api), no tools.

Digests pinned for baserow and letta; invoicing keeps private-registry
tags (DIGEST-UNRESOLVED). redis-cache and mongodb-database consumer
shapes are inferred (no prior consumer in the catalog).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 01:12:50 +02:00
jschoubben 3b5f214f3d Merge pull request 'mosquitto: run-once dynsec bootstrap + fix the exit-0-on-failure provisioner' (#8) from feat/mosquitto-bootstrap into main 2026-09-06 01:06:08 +02:00
jschoubben e58cbc1c46 mosquitto: don't read a rejected dynsec command as success
mosquitto_ctrl's dynsec subcommands exit 0 even when they fail — a
"Client not found", an "already exists", a "Connection error: Not
authorized", an "Unable to connect" all return status 0 and report the
failure only as a line of text (verified live against 2.0.11). ctl()
trusted the exit code, so clientExists()'s getClient probe never threw
and always returned true; createScopedClient therefore took the
setClientPassword branch, never ran createClient, and the consumer's
client never landed in the dynsec store — while the provisioner logged
it as provisioned. That is the assigned-catalogue-mqtt failure.

ctl() now scans the combined stdout/stderr for the tool's error markers
and raises a match as the failure it is. Surfacing those errors exposed
two calls that only "worked" by being swallowed: addRoleACL re-run
reports "already exists" (now ignored like createRole), and addClientRole
re-run reports a bare "Internal error" that cannot be told from a real
fault — so the binding is checked with clientHasRole and only added when
absent. Fresh provision, idempotent re-provision, password rotation, bad
admin auth and unreachable broker all verified against a live broker.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 00:57:45 +02:00
jschoubben ec1e7189a3 mosquitto: seed dynsec with a run-once bootstrap before the broker
The Dynamic Security plugin refuses to start the broker unless
dynamic-security.json already holds an admin client, and no reconcile loop
seeds it (novox/hq ADR 0052). Add a run-once init container, declared before
the server container, that runs mosquitto's own bootstrap entrypoint in the
runtime image: it seeds the store offline via mosquitto_ctrl and exits, and
the host gates the broker on its completion.

The bootstrap hands the seeded file to the broker's user (uid 1883, chown +
0600): the broker must read the seed at startup AND persist to it as clients
come and go, but the init container runs as root and would otherwise leave a
file the broker can neither read nor rewrite. This is the ownership question
ADR 0052 left for the lab to settle. It seeds only when the file is absent, so
what the running plugin grows is never clobbered (issue 035).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-06 00:33:28 +02:00
jschoubben eb5cf65be3 Merge pull request 'Servarr events entrypoints don't crash the runtime when unconfigured' (#7) from fix/arr-events-guard into main 2026-09-05 23:14:43 +02:00
jschoubben 419dd68145 Servarr events: don't crash the runtime when unconfigured
The radarr/sonarr/lidarr/bookshelf events entrypoints built their client
with XClient.fromEnv() at import time, which throws when no API key/URL is
available yet — crash-looping the runtime container. The tools already
guard this; the events entrypoint did not.

Mirror the tools' guard: build the client in a try/catch, and only start
the poll loop when it succeeds. When it fails, log one line and stay idle
until a key is available. Behaviour when configured is unchanged.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-05 23:13:56 +02:00
jschoubben 50eb1e6f02 Merge pull request 'Media modules access the shared library, they do not own it (ADR 0051)' (#6) from feat/media-access-not-ownership into main 2026-09-05 22:48:16 +02:00
jschoubben 69c8a5edf8 media stack: the shared library is accessed, not owned
04-ISSUES/036: eight media modules each declared the shared library and
download directories under /services/media/* as their own `directory`
resources. Six of them owning one path is the collision the resolver
refuses — so the stack's only sensible assignment, all on one machine
sharing one filesystem, would be refused the first time two landed
together.

The media library is the operator's, owned by no module (novox/hq
ADR 0051). Move every /services/media/* path from an owned `directory`
resource to an `accesses` entry: the mesh mounts it and owns nothing —
does not create, chown, reconcile or remove it — and several modules may
access one path with no conflict. Each module's own config and mesh-state
directories stay owned resources.

Modes are least-privilege: plex reads the libraries it streams; the
managers and download clients get read-write on what they import and
write; bazarr writes subtitles into the libraries (read-write) and only
reads the download spool. The container volume mounts are unchanged.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-05 22:14:33 +02:00
jschoubben f45cce1679 Merge pull request 'Resync hq ADR references (0044-0054 -> 0039-0049)' (#5) from feat/adr-ref-resync into main 2026-09-05 12:49:51 +02:00
jschoubben 6e8c18afff Resync hq ADR references 0044-0054 -> 0039-0049 after the hq record reconciliation 2026-09-05 12:49:16 +02:00
jschoubben afc1ae1ecf Merge pull request 'Convert four more hal modules: bookshelf, unifi, fail2ban, marrytts' (#4) from feat/convert-apps-batch-2 into main 2026-09-05 12:34:53 +02:00
jschoubben 229c6a83d1 Convert four more hal modules: bookshelf, unifi, fail2ban, marrytts
- bookshelf: Servarr v1 fork on the radarr template (4 tools).
- unifi: portainer-shaped tooled app (7 tools, 9 ports), settings-merged config.
- fail2ban: host-level security module mirroring firewall (service + restart-on,
  no container); ban actions preserved as source ufw/iptables and FLAGGED to be
  rewritten nftables-native before it actually bans.
- marrytts: manifest-only plain container (no tools), like resolv-conf.

All typecheck against the built @novox/mesh-sdk; service images digest-pinned.
Held from merge pending the hq initialization reconciliation.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-05 12:04:14 +02:00
jschoubben 8aa09c013c Merge pull request 'Convert four hal modules: lidarr, mongodb, mssql, mosquitto' (#3) from feat/convert-remaining-services into main 2026-09-05 04:17:47 +02:00
jschoubben c82b3ff706 Convert four hal modules: lidarr, mongodb, mssql, mosquitto
Mirrors the proven catalog patterns field-for-field:
- lidarr  -> the Servarr twin of radarr/sonarr (API v1, artist content); no
  provisioner (it is a consumer app).
- mongodb -> postgres shape: mongodb-database provider, provisioner mints a
  per-consumer db+user (ADR 0053), client shells to mongosh (no npm driver,
  the psql convention).
- mssql   -> postgres shape: mssql-database provider, sqlcmd client.
- mosquitto -> redis shape: mqtt-topic provider via the Dynamic Security
  plugin, deliberately avoiding hal's password_file (that file is nox issue
  011 exactly); provisioner mints a per-consumer MQTT client+role.

All four typecheck (strict, NodeNext) against the built @novox/mesh-sdk, and
their service images are digest-pinned to resolved registry digests. The
mesh-runtime-<mod> images keep the all-zeros placeholder the pipeline pins,
as postgres/redis do, and must bundle each module's CLI (mongosh/sqlcmd/
mosquitto_ctrl) as mesh-runtime-postgres bundles psql.

Not yet lab-verified: each module lists in-code what an integration test must
prove (auth model, provisioner reconcile, mosquitto dynsec bootstrap ordering).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-05 04:17:07 +02:00
jschoubben bca68c2109 Merge pull request 'audit-logger: the assigned-module manifest (ADR 0048)' (#2) from events/audit-logger-assigned into main 2026-09-05 03:06:59 +02:00
jschoubben 08bdd0e456 Providers: broker-bound runtime container replaces the old provisioner (ADR 0052/0053)
Each provider's separate provisioner container becomes a runtime container that
serves the module's tools and runs its provisioner under the module's scoped broker
account: mesh-runtime-<module>, on the backend's own network (reaching the backend
by name and the broker by NAT), with MESH_BROKER_FILE + MESH_RECEIVES replacing
GRANTS. umami gains the broker own-secret it lacked. cloudflare-dns's adapter is
re-pointed at the ADR 0053 contract (a data provision, like umami — its record
return is the scoped-out concern).

Proven: provider-on-backend-network green — redis's runtime, on the private redis
network, binds the broker and provisions a consumer with the mesh's credential.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-05 00:52:09 +02:00
jschoubben 3b03fcd8f7 Providers create the credential the mesh minted, sealing nothing (ADR 0053)
redis, postgres and minio adapters drop generatePassword + the returned credential:
each creates the resource under the login the mesh derived (`as`) with the password
the mesh minted (`p.password`). minio's client gains a secret-key argument so it sets
the mesh's secret rather than generating one. umami (analytics) is re-pointed at the
new contract too; its siteId return is a data-provision concern ADR 0053 scopes out.

Proven: mesh-lab provider-uses-mesh-credential green — redis creates the consumer's
login with the mesh's password, the consumer authenticates (PONG), no seal key set.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-05 00:27:37 +02:00
jschoubben 550393b847 Runtime config: dedicated mergeable file + restart-on so settings take effect (issue 009)
Give each settings-config runtime a mergeable config file with its own id
(runtime-config) — the previous "config" collided with modules that already own a
config directory, so ten runtimes mounted a config file no resource declared. Point
each runtime's restart-on at it, so a settings change recreates the runtime and it
re-reads the new value (needs the mesh-host container restart-on fix on
issue/009-container-restart-on). Proven: runtime-restart-on-config e2e green.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 23:40:07 +02:00
jschoubben 9e156a5b9e Roll out the tool runtime to the remaining tools+events modules (ADR 0052/0051)
Nineteen modules gain a broker-bound runtime container that serves the module's
tools under its own scoped account: bazarr, gitea, grafana, home-assistant,
icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi,
photos, portainer, qbittorrent, searxng, tautulli, verdaccio.

Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv
overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env
fallbacks, so URL and credentials come from `settings set`, with the URL defaulting
to the server on the node. nextcloud and mailu also mount the docker socket for
their exec-based tools.

Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token,
the runtime reads the merged config and serves grafana's tools under the scoped
account, with nothing in the manifest. Two gaps this surfaced are filed as hq
issues 008 (a provider runtime's seal key) and 009 (a settings change does not
restart a container runtime).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 23:08:40 +02:00
jschoubben 7b55e834e9 sonarr, radarr: tool runtime container + self-detect API key from config.xml (ADR 0052)
Mirrors plex: a broker-bound runtime that serves the module's tools, discovering
the app's API key from its own config.xml under a read-only config-dir mount, URL
defaulting to the server on the node. Proven in the mesh-lab: assigned-sonarr green
(key detected, tools served under the scoped account, no live Sonarr needed).

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 22:54:53 +02:00
jschoubben 6615b5e3b3 plex, redis: module runtime containers + declare self-consumed events (ADR 0052/0046)
plex gains a broker-bound tools/events runtime container (mesh-runtime-plex)
alongside its server, and a never-throwing plex_reachable health probe. redis and
postgres subscribe to their own lifecycle events in index.ts but declared no
consumes — so the substrate never made the queue the runtime binds and it crashed
on start (404). Declare the consume, as ADR 0046 requires. Proven end-to-end in the
mesh-lab: assigned-plex and assigned-redis both green.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 22:29:41 +02:00
jschoubben 67d8d0dcbd catalogue: one mesh-state namespace — /var/lib/mesh/<module>/ (consistency)
The review found broker own-secret paths drifting: mostly /var/lib/<module>/
broker, but grafana/redis/icecast/nextcloud carried a '-module' suffix to dodge
a collision with the service's own /var/lib/<name> data, and photos sat under
/etc. Normalized to one collision-free namespace a service never owns:
/var/lib/mesh/<module>/broker, with a mesh-state directory resource for the
parent, across 24 modules. audit-logger is grandfathered (lab-proven, referenced
by the assigned test, and it has no service to collide with). All 33 manifests
parse; no client hardcoded a path, so nothing in code moved.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-04 21:28:55 +02:00
jschoubben 5fd78a77ad minio: provisioner emits best-effort, so a missing broker can't fail provisioning
The review found minio's provisioner emitting with a bare await, which throws
when no broker is bound (a provisioner is not yet a runtime — ADR 0052) and so
fails create/remove. Wrapped like the other providers: the event is logged and
dropped, the bucket still made. The real fix — the provisioner carrying a broker
credential — is ADR 0052.
2026-09-04 21:25:42 +02:00
jschoubben 121b7f196b cloudflare-dns: config from settings, not static manifest env (ADR 0051)
Which zone, domain and ingress are a mesh's facts, not the module's — so they
are settings merged into a config file the mesh manages, read by fromEnv, rather
than the empty env placeholders I wrongly baked in. The token stays the one
own-secret. The module now describes a Cloudflare registrar; which zone is a
setting, so the same description serves every mesh. Typechecks; manifest parses.
2026-09-04 21:07:51 +02:00
jschoubben d2d20a76b6 cloudflare-dns: a public-dns provider (ADR 0049)
The first registrar behind the neutral public-dns interface. Provider shape
like minio: provides public-dns, a provisioner that registers a consumer's
public name at Cloudflare pointing it at the mesh's ingress, and removes it on
withdrawal. The name is derived from the consumer identity under the mesh's
domain (so stateless teardown recomputes it); the returned {fqdn,target,ttl}
is public, the Cloudflare token the only secret and it never leaves. Emits
record.created/.removed (best-effort). A cloudflare_dns_records diagnostic tool.
Config (zone, domain, ingress) is left to settings, so it fails closed until a
mesh provides them. Typechecks; manifest parses.
2026-09-04 20:55:36 +02:00
jschoubben 0e102dd350 firewall: the module that applies the mesh-computed packet filter (ADR 0050)
The missing applier. mesh-control already derives a node's whole nftables rule
set from the union of its modules' listens and writes it to /etc/nftables.conf;
this module declares filtering:{into} to receive it and loads it — the nftables
service, reloaded on 'filtering' whenever the rules change. A firewall_rules
tool reads the live table so a declared scope can be checked against what is
really enforced. Closes the loop from listens.from to a packet actually dropped.
Manifest parses; tool typechecks.
2026-09-04 20:52:26 +02:00
jschoubben d59649de0a dnsmasq: a resolver tool and event (ADR 0044/0046)
The mesh's resolver is more than config after all. Tools: dnsmasq_names (what
this node answers, from the generated wildcard file) and dnsmasq_resolve (resolve
a name through this node's own resolver — the check that wildcard-resolution
actually answers). Event: it watches the wildcard file and emits
module.dnsmasq.name.added/.removed as machines' names become resolvable here —
DNS having propagated to this node, distinct from the mesh's node.* events.
Typechecks; manifest parses.
2026-09-04 10:24:03 +02:00
jschoubben 8f8e0153b1 searxng, icecast, photos: full nox modules (ADR 0044/0046)
searxng: a search tool — tools-only, a stateless search has nothing to observe.
icecast: status tool, emits stream.started/.stopped by diffing live mountpoints.
photos: status/albums/recent tools, emits item.added (immich-shaped API, coded
defensively since the stub ships a placeholder image — flagged in the code).
Typecheck; manifests parse.
2026-09-04 02:46:24 +02:00
jschoubben 81dc74734b registry, verdaccio, portainer: full nox modules (ADR 0044/0046)
registry (docker v2): catalog/tags/delete tools, emits image.pushed (a build's
image is now pullable). verdaccio (npm): list/info tools, emits
package.published. portainer: endpoints/stacks/containers tools — tools-only,
since its only events are the underlying containers' lifecycle, which the host
owns. Typecheck; manifests parse.
2026-09-04 02:45:42 +02:00
jschoubben aca237b2e0 home-assistant, influxdb: full nox modules (ADR 0044/0046)
home-assistant: states/call-service/config tools, emits state.changed bounded
to actuator/contact domains (not attribute ticks), overridable via a watch
allowlist. influxdb: health/buckets/flux-query tools — tools-only, since a
time-series DB has no lifecycle event to emit here. Typecheck; manifests parse.
2026-09-04 02:44:17 +02:00
jschoubben eccfc70991 grafana, tautulli, nextcloud, nodered: full nox modules (ADR 0044/0046)
grafana: status/datasources/dashboards/alerts tools, emits alert.firing.
tautulli: activity/history/stats tools, emits watch.recorded. nextcloud:
users/shares/apps/occ tools (occ via docker exec, shares over OCS), emits
user.created/share.created. nodered: flows/nodes/deploy tools, emits
flows.deployed inline from the deploy tool. All typecheck; manifests parse.
2026-09-04 02:43:20 +02:00
jschoubben 1e2a849b90 nzbget, qbittorrent: full nox downloader modules (ADR 0044/0046)
nzbget (usenet, JSON-RPC) and qbittorrent (torrents, WebUI API with manual SID
session). Tools: status, queue/torrents, add, pause/resume, delete. Both poll
and emit module.<app>.download.added and module.<app>.download.completed — the
key plex consumes to rescan; completion is keyed off real success (nzbget
history SUCCESS, qbittorrent progress reaching 1), not mere queue disappearance,
so a failed or deleted item is not reported as done. Typecheck; manifests parse.
2026-09-04 02:40:06 +02:00