feat: runtime config management API + supporting backend work#24
Open
roziscoding wants to merge 22 commits into
Open
feat: runtime config management API + supporting backend work#24roziscoding wants to merge 22 commits into
roziscoding wants to merge 22 commits into
Conversation
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.
🐳 Docker image publishedThis PR has been built and pushed to GHCR: Pull and run it locally: docker pull ghcr.io/roziscoding/jack:pr-24
docker run --rm ghcr.io/roziscoding/jack:pr-24
|
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.
roziscoding
commented
Jun 18, 2026
roziscoding
commented
Jun 18, 2026
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
getManagementApp+ a 2ndBun.serveonMANAGEMENT_PORT), guarded byX-Management-Key(constant-time compare), started only whenMANAGEMENT_KEYis set. The public peer port never exposes/config.ConfigService: one in-process serialized write queue; rollback-safe atomic file writes (tmp→rename, randomized temp name, preserved perms); persists the raw config so{env}/{file}secret refs are never resolved into the file.peer_iddownload cascade./torznab,/peer,/items,/servers, qB) reads connectors live, so add/remove is searchable without restart..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)
noUnusedLocals/Parameters).ConnectorManager-shaped deps).Notes
MANAGEMENT_KEYis operator-set via env (no first-boot key generation).Testing
bun test: 223 pass / 0 fail ·tsc --noEmit: clean ·eslint .: cleanGreptile 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.jsoncfile remains the source of truth; mutations flow through a serialized write queue with rollback-safe atomic writes and preserve{env}/{file}secret references verbatim.Bun.serveonMANAGEMENT_PORT(default 5226), guarded by SHA-256 constant-timeX-Management-Keycompare; full peers/servers CRUD with liveConnectorManagerreconciliation, download-row cascade on peer URL rekey, and soft-delete drain semantics.setSpanAttribute/setSpanAttributesfunnel inspan-attributes.tsreplaces scattered directspan.setAttributecalls, 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, andlib/config.tsis consistent and correct.Important Files Changed
canMutategates mutation route registration; ZodError → BadRequestError funnel inmutate.canMutate; useshono-openapizValidator for request-body pre-validation before controller dispatch.migrateConfigusesz.looseObjectso downgrade/catch on version only resets the version field — all other config data is preserved.getAppConfignow returns both resolvedappConfigand the raw ref-preserving object.ConnectorManager; liveservers/peers/destinations/sourcesgetters filter byenabled;init()is idempotent (pending/initialized guard) so dynamic add is safe.enabled/disable()/enable()for soft-delete;_initializationeagerly initialized withPromise.withResolvers(non-nullable now);generateIdexported; span calls routed through newsetSpanAttributefunnel.redactUrlmasks only sensitive query-param values.ConnectorManager, optionalConfigService, and astartManagementServerhelper that guards against port collision and ensures the management listener is shut down alongside the public server.secureHeaders+requireManagementKeyon*; 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%%{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 trueReviews (4): Last reviewed commit: "fix: seed config service with default co..." | Re-trigger Greptile