Skip to content

feat: runtime config management API + supporting backend work#24

Open
roziscoding wants to merge 22 commits into
mainfrom
feat/ui
Open

feat: runtime config management API + supporting backend work#24
roziscoding wants to merge 22 commits into
mainfrom
feat/ui

Conversation

@roziscoding

@roziscoding roziscoding commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Context

Adds a runtime config management API so peers, servers, and settings can be managed live (no restart), plus the supporting backend work this branch accumulated. jack's config stays a hand-editable, version-controllable config.jsonc (the file is the source of truth) — the management API mutates it through a single serialized writer and applies changes to the live connector map.

What's included

Config management API (the main deliverable)

  • A separate management surface on its own port (getManagementApp + a 2nd Bun.serve on MANAGEMENT_PORT), guarded by X-Management-Key (constant-time compare), started only when MANAGEMENT_KEY is set. The public peer port never exposes /config.
  • ConfigService: one in-process serialized write queue; rollback-safe atomic file writes (tmprename, randomized temp name, preserved perms); persists the raw config so {env}/{file} secret refs are never resolved into the file.
  • Live peers CRUD: add / remove (disable-as-soft-delete, in-flight drains) / rename / URL-change rekey with a peer_id download cascade.
  • Live servers CRUD (+ source/destination list reconciliation).
  • Live connector visibility: search fan-out (/torznab, /peer, /items, /servers, qB) reads connectors live, so add/remove is searchable without restart.
  • Migration write-back: a migrated config is persisted with a .bak; also fixes a pre-existing crash where an up-to-date config failed to parse on the next boot.

Supporting backend work (earlier commits on this branch)

  • Sensitive-field redaction in logs + a span-attribute redacting funnel.
  • Versioned config migration system; TypeScript 6 bump (noUnusedLocals/Parameters).
  • Connector-lifecycle refactor (decorator rename; ConnectorManager-shaped deps).

Notes

  • No UI yet — the management API is the backend foundation; UI/CLI/BFF are out of scope.
  • MANAGEMENT_KEY is operator-set via env (no first-boot key generation).

Testing

  • bun test: 223 pass / 0 fail · tsc --noEmit: clean · eslint .: clean

Greptile Summary

This PR delivers a runtime config management API on a separate, key-guarded port plus the supporting backend work accumulated on the branch (migration system, connector-lifecycle refactor, span-attribute redaction funnel, TypeScript 6 upgrade). The config.jsonc file remains the source of truth; mutations flow through a serialized write queue with rollback-safe atomic writes and preserve {env}/{file} secret references verbatim.

  • Config management API: A second Bun.serve on MANAGEMENT_PORT (default 5226), guarded by SHA-256 constant-time X-Management-Key compare; full peers/servers CRUD with live ConnectorManager reconciliation, download-row cascade on peer URL rekey, and soft-delete drain semantics.
  • Migration system: Versioned migrations run on boot, back up the original file, and atomically rewrite the upgraded config; also fixes the prior crash where an already-current config failed to parse on re-boot.
  • Span-attribute redaction: A single setSpanAttribute/setSpanAttributes funnel in span-attributes.ts replaces scattered direct span.setAttribute calls, enforcing redaction and 8 KB truncation uniformly.

Confidence Score: 5/5

Safe to merge — the management API is well-isolated on its own port, file writes are atomic and rollback-safe, and the serialized write queue prevents concurrent mutation races.

The rollback-safe persist-then-commit ordering in ConfigService, idempotent init() guard in ServerConnector, constant-time key comparison, and migration backup all work correctly. No mutations are reachable without the management key, and the management port is never exposed by the public Bun.serve. No logic bugs found in any changed path.

No files require special attention — the core logic in config.service.ts, lib/servers/index.ts, and lib/config.ts is consistent and correct.

Important Files Changed

Filename Overview
apps/backend/src/modules/config/config.service.ts New file: serialized write queue, rollback-safe atomic persist, full peers/servers CRUD with live connector reconciliation and peer-rekey cascade. Logic is sound and well-commented.
apps/backend/src/modules/config/config.controller.ts New file: thin controller wrapping ConfigService; canMutate gates mutation route registration; ZodError → BadRequestError funnel in mutate.
apps/backend/src/modules/config/config.router.ts New file: mutation routes only registered when canMutate; uses hono-openapi zValidator for request-body pre-validation before controller dispatch.
apps/backend/src/middleware/require-management-key.ts New file: SHA-256 hash of both sides before XOR loop comparison — correct constant-time, length-independent key check.
apps/backend/src/lib/config.ts Versioned migration system added; migrateConfig uses z.looseObject so downgrade/catch on version only resets the version field — all other config data is preserved. getAppConfig now returns both resolved appConfig and the raw ref-preserving object.
apps/backend/src/lib/servers/index.ts Replaces static connector arrays with ConnectorManager; live servers/peers/destinations/sources getters filter by enabled; init() is idempotent (pending/initialized guard) so dynamic add is safe.
apps/backend/src/lib/servers/base.ts Added enabled/disable()/enable() for soft-delete; _initialization eagerly initialized with Promise.withResolvers (non-nullable now); generateId exported; span calls routed through new setSpanAttribute funnel.
apps/backend/src/lib/atomic-write.ts New file: randomized temp name, permission-preserving chmod, rename(2) atomicity, cleanup on failure — correct design for crash-safe config writes.
apps/backend/src/lib/span-attributes.ts New file: single funnel for all span attribute writes; redacts sensitive fields, serializes objects, caps at 8 KB; redactUrl masks only sensitive query-param values.
apps/backend/src/index.ts Wires ConnectorManager, optional ConfigService, and a startManagementServer helper that guards against port collision and ensures the management listener is shut down alongside the public server.
apps/backend/src/management-app.ts New file: isolated Hono app with secureHeaders + requireManagementKey on *; no public routes share this surface.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client as API Client
    participant Mgmt as Management Server (MANAGEMENT_PORT)
    participant MW as requireManagementKey
    participant Ctrl as ConfigController
    participant Svc as ConfigService
    participant Q as Serialized Queue
    participant CM as ConnectorManager
    participant FS as config.jsonc

    Client->>Mgmt: PATCH /config/peers/:id X-Management-Key
    Mgmt->>MW: validate key (SHA-256 constant-time)
    MW-->>Mgmt: 401 if invalid
    Mgmt->>Ctrl: updatePeer(id, body)
    Ctrl->>Svc: updatePeer(id, input)
    Note over Svc: PeerConfig.parse resolve secrets / RawPeerConfig preserve refs
    Svc->>Q: enqueue task
    Q->>FS: atomicWrite(newConfig) via tmp rename
    Q->>Svc: "this.raw = next (commit in-memory)"
    Q->>CM: addPeerConnector(resolved) init
    alt URL changed
        Q->>CM: removeConnector(oldId) disable
        Q->>DB: reassignPeerId(oldId, newId)
    end
    Q-->>Svc: resolve
    Svc-->>Ctrl: ok
    Ctrl-->>Client: 200 ok true
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client as API Client
    participant Mgmt as Management Server (MANAGEMENT_PORT)
    participant MW as requireManagementKey
    participant Ctrl as ConfigController
    participant Svc as ConfigService
    participant Q as Serialized Queue
    participant CM as ConnectorManager
    participant FS as config.jsonc

    Client->>Mgmt: PATCH /config/peers/:id X-Management-Key
    Mgmt->>MW: validate key (SHA-256 constant-time)
    MW-->>Mgmt: 401 if invalid
    Mgmt->>Ctrl: updatePeer(id, body)
    Ctrl->>Svc: updatePeer(id, input)
    Note over Svc: PeerConfig.parse resolve secrets / RawPeerConfig preserve refs
    Svc->>Q: enqueue task
    Q->>FS: atomicWrite(newConfig) via tmp rename
    Q->>Svc: "this.raw = next (commit in-memory)"
    Q->>CM: addPeerConnector(resolved) init
    alt URL changed
        Q->>CM: removeConnector(oldId) disable
        Q->>DB: reassignPeerId(oldId, newId)
    end
    Q-->>Svc: resolve
    Svc-->>Ctrl: ok
    Ctrl-->>Client: 200 ok true
Loading

Reviews (4): Last reviewed commit: "fix: seed config service with default co..." | Re-trigger Greptile

Introduce a dedicated management surface (getManagementApp) served on its own
MANAGEMENT_PORT via a second Bun.serve, guarded by X-Management-Key
(constant-time compare) and started only when MANAGEMENT_KEY is set. Adds
GET /config, /config/peers, /config/servers reading the live ConnectorManager.
ConfigService is the single serialized writer: holds the raw (ref-preserving)
config object, persists atomically (tmp + rename) through an async-mutex queue,
and reconciles the live connector map. addPeer validates + resolves secrets,
rejects duplicate url/name (409), strips unknown keys before persisting, and adds
a live PeerConnector. POST /config/peers wired into the management app.
Connector-map getters now honor the enabled flag (peers/servers/sources/
destinations/connectors skip disabled connectors; sources/destinations also gate
on canSource/canDestination). removePeer persists the removal then disables the
live connector (in-flight drains, restart prunes). Same-URL updatePeer replaces
the connector under its stable id. Adds NotFoundError (404); DELETE/PATCH
/config/peers/:id routes.
updatePeer now handles a changed URL inside the serialized write: persist the
file, add the connector under the new id, drain the old one (disable), and cascade
download rows via DownloadsRepository.reassignPeerId (manual ON UPDATE CASCADE) so
downloads follow the peer. Rejects a URL that collides with another peer (409).
addServer/removeServer/updateServer mirror the peer lifecycle through the same
serialized write queue. addServerConnector now reconciles _sourceIds/_destinationIds
so a live-added server is immediately a usable source/destination, and a toggled
capability drops out. URL change rekeys the connector (no download cascade; *arr
re-registration needs a restart). POST/DELETE/PATCH /config/servers.
… crash

getAppConfig now returns a shared { appConfig, raw } so ConfigService is seeded
from the same parsed object (no divergent second read). On a real migration it
backs up the original bytes to <path>.bak (comments intact) and atomically
rewrites the file. Using 'migrated ?? fileContent' also fixes a pre-existing crash
where an already-current config threw AppConfig.parse(undefined) on the next boot.
Fan-out consumers now read connectors live from ConnectorManager instead of
snapshot arrays captured at boot, so management-API add/remove is visible without
restart. Object-taking controllers (Servers/Items/qB) get lazy getter objects;
array-taking consumers (PeerController, TorznabController, getDownloadRouter)
take () => Connector[] providers.
- ConfigService: collapse the 6 near-identical add/remove/update methods behind
  generic #addEntry/#removeEntry/#updateEntry helpers parameterized by slice + an
  optional onRekey hook (peers cascade download rows; servers don't).
- ConfigController: funnel all mutations through one #mutate helper (service-presence
  guard + ZodError->400); expose canMutate.
- Router: mount mutation routes only when a ConfigService is wired, so an
  unconfigured management surface returns 404 instead of 500.
- atomic-write: randomized temp name, preserve target perms (new files 0o600),
  cleanup-on-error.
- Document removeConnector's resident-until-restart trade-off.
Finishes the in-progress refactor and fixes its mechanical fallout:
- DownloadsService and getApp accept the structural { peers } / { servers, peers }
  shape they actually use, so a real ConnectorManager (live) or a lightweight test
  object both satisfy them.
- Widen the base ServerConnector input so PeerConnector's type: 'jack' plumbs
  through ConnectorType; ArrServerConnector tolerates omitted headers (base defaults
  to {}), fixing the radarr/sonarr ctors.
- Rename the init decorator require->requiresInitialization and fix its docstring.
- Update tests to the new signatures; peer-download's markInitialized now resolves
  the initialization promise the @requiresInitialization guard awaits.

Full suite: 223 pass / 0 fail; tsc clean; lint clean.
@github-actions

github-actions Bot commented Jun 14, 2026

Copy link
Copy Markdown

🐳 Docker image published

This PR has been built and pushed to GHCR:

ghcr.io/roziscoding/jack:pr-24

Pull and run it locally:

docker pull ghcr.io/roziscoding/jack:pr-24
docker run --rm ghcr.io/roziscoding/jack:pr-24

Last built from commit f585c72. Heads up: this image is automatically deleted when the PR is closed.

Comment thread apps/backend/src/lib/config.ts
Comment thread apps/backend/src/index.ts Outdated
A failing catch on the whole looseObject parse replaced the entire
config with { version: 0 } on any version validation failure (including
version > LATEST_MIGRATION downgrades), wiping servers/peers/jack before
the write-back. Move the catch onto the version field so the rest of the
config is preserved.
The process keeps serving the public port when MANAGEMENT_PORT collides;
only the management API is skipped. Use logger.error so fatal-level
alerting does not page on a non-terminal condition.
Comment thread apps/backend/src/modules/config/config.router.ts Outdated
Comment thread apps/backend/src/index.ts Outdated
… unset

When no config file exists and the default config's referenced secrets can't be
resolved, return DEFAULT_APP_CONFIG (the bytes just written to disk) as the raw
base instead of EMPTY_APP_CONFIG, so the first management mutation no longer
clobbers the jack template from the file.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant