From a71c2ff14e7fe7b466f3006bf8558ba4170ddf7e Mon Sep 17 00:00:00 2001 From: Roz Date: Wed, 10 Jun 2026 15:14:29 +0200 Subject: [PATCH 01/22] feat: add versioned config migrations --- apps/backend/src/__tests__/config.test.ts | 232 ++++------------------ apps/backend/src/lib/config.ts | 33 ++- 2 files changed, 75 insertions(+), 190 deletions(-) diff --git a/apps/backend/src/__tests__/config.test.ts b/apps/backend/src/__tests__/config.test.ts index 99fc16e..8a88dd3 100644 --- a/apps/backend/src/__tests__/config.test.ts +++ b/apps/backend/src/__tests__/config.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import z from 'zod' -import { AppConfig, ConfigSecret, JackConfig, PeerConfig, ServerConfig } from '../lib/config' +import { ConfigSecret, migrateConfig, MIGRATIONS } from '../lib/config' const HEX_KEY = '0123456789abcdef0123456789abcdef' @@ -70,10 +70,6 @@ describe('configSecret', () => { expect(result.error?.issues[0]?.message).toContain(missingFile) }) - test('rejects an empty plain string by default', () => { - expect(ConfigSecret().safeParse('').success).toBe(false) - }) - test('rejects an empty file-resolved string by default', () => { expect(ConfigSecret().safeParse({ file: emptyFile }).success).toBe(false) }) @@ -96,204 +92,62 @@ describe('configSecret', () => { expect(secret.parse({ file: hexFile })).toBe(HEX_KEY) expect(secret.safeParse({ file: secretFile }).success).toBe(false) }) - - test('exposes string | { env } | { file } as input and string as output', () => { - const _secret = ConfigSecret() - const _in1: z.input = 'literal' - const _in2: z.input = { env: 'X' } - const _in3: z.input = { file: '/run/secrets/x' } - const _out: z.output = 'a-string' - expect([_in1, _in2, _in3, _out]).toBeDefined() - }) }) -describe('appConfig parsing', () => { - const savedEnv = { ...process.env } - let headerSecretFile: string - let jackSecretFile: string - let radarrKeyFile: string - let tempDir: string - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'jack-app-config-')) - headerSecretFile = join(tempDir, 'header-secret') - jackSecretFile = join(tempDir, 'jack-secret') - radarrKeyFile = join(tempDir, 'radarr-key') - - writeFileSync(headerSecretFile, 'header-file-secret\n') - writeFileSync(jackSecretFile, 'jack-file-secret\n') - writeFileSync(radarrKeyFile, `${HEX_KEY}\n`) - - process.env.JACK_KEY = 'jack-secret' - process.env.RADARR_KEY = HEX_KEY - process.env.HEADER_SECRET = 'header-secret' - }) - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }) - process.env = { ...savedEnv } - }) - - test('parses a servers + peers config', () => { - const parsed = AppConfig.parse({ - jack: { baseUrl: 'http://jack:3000', apiKey: 'jack-key' }, - servers: [ - { name: 'radarr', type: 'radarr', url: 'http://radarr:7878', apiKey: HEX_KEY }, - ], - peers: [{ name: 'friend', url: 'http://peer:3000', apiKey: 'peer-key' }], - }) - - expect(parsed.jack?.apiKey).toBe('jack-key') - expect(parsed.servers[0]?.apiKey).toBe(HEX_KEY) - expect(parsed.servers[0]?.headers).toEqual({}) - expect(parsed.peers[0]?.apiKey).toBe('peer-key') - expect(parsed.peers[0]?.headers).toEqual({}) - }) - - test('defaults source/destination/autoregister', () => { - const parsed = AppConfig.parse({ - servers: [{ name: 'radarr', type: 'radarr', url: 'http://radarr:7878', apiKey: HEX_KEY }], - }) - - const server = parsed.servers[0]! - expect(server.source).toBe(true) - expect(server.destination).toBe(true) - expect(server.autoregister).toEqual({ enable: true, priority: 1 }) - }) - - test('respects explicit source/destination/autoregister', () => { - const parsed = AppConfig.parse({ - servers: [{ - name: 'sonarr', - type: 'sonarr', - url: 'http://sonarr:8989', - apiKey: HEX_KEY, - source: false, - destination: true, - autoregister: { enable: false, priority: 5 }, - }], - }) - - const server = parsed.servers[0]! - expect(server.source).toBe(false) - expect(server.destination).toBe(true) - expect(server.autoregister).toEqual({ enable: false, priority: 5 }) +describe('migrateConfig', () => { + test('migrates a versionless config up to the latest version', () => { + const result = migrateConfig({ servers: [], peers: [] }) + expect(result).toBeDefined() + expect(result!.version).toBe(MIGRATIONS.length) }) - test('defaults servers and peers to empty arrays', () => { - const parsed = AppConfig.parse({}) - expect(parsed.servers).toEqual([]) - expect(parsed.peers).toEqual([]) + test('preserves the existing fields while migrating', () => { + const result = migrateConfig({ servers: ['a'], peers: ['b'], extra: 'kept' }) + expect(result).toMatchObject({ servers: ['a'], peers: ['b'], extra: 'kept' }) }) - test('resolves env-reference api keys into plain strings', () => { - const parsed = AppConfig.parse({ - jack: { baseUrl: 'http://jack:3000', apiKey: { env: 'JACK_KEY' } }, - servers: [{ name: 'radarr', type: 'radarr', url: 'http://radarr:7878', apiKey: { env: 'RADARR_KEY' } }], - }) - - expect(parsed.jack?.apiKey).toBe('jack-secret') - expect(parsed.servers[0]?.apiKey).toBe(HEX_KEY) + test('treats an explicit version of 0 as unmigrated and runs every migration', () => { + const result = migrateConfig({ version: 0, foo: 'bar' }) + expect(result).toBeDefined() + expect(result).toMatchObject({ foo: 'bar' }) + expect(result!.version).toBe(1) }) - test('resolves file-reference api keys into plain strings', () => { - const parsed = AppConfig.parse({ - jack: { baseUrl: 'http://jack:3000', apiKey: { file: jackSecretFile } }, - servers: [{ name: 'radarr', type: 'radarr', url: 'http://radarr:7878', apiKey: { file: radarrKeyFile } }], - }) - - expect(parsed.jack?.apiKey).toBe('jack-file-secret') - expect(parsed.servers[0]?.apiKey).toBe(HEX_KEY) + test('treats a non-numeric version as unmigrated', () => { + const result = migrateConfig({ version: 'nope' as unknown as number }) as Record + expect(result).toBeDefined() + expect(result.version).toBe(1) }) - test('resolves custom server and peer headers', () => { - const parsed = AppConfig.parse({ - servers: [{ - name: 'radarr', - type: 'radarr', - url: 'http://radarr:7878', - apiKey: HEX_KEY, - headers: { - 'X-Literal': 'literal-header', - 'X-Secret': { env: 'HEADER_SECRET' }, - 'X-Secret-File': { file: headerSecretFile }, - }, - }], - peers: [{ - name: 'friend', - url: 'http://peer:3000', - apiKey: 'peer-key', - headers: { - 'X-Peer-Secret': { env: 'HEADER_SECRET' }, - 'X-Peer-Secret-File': { file: headerSecretFile }, - }, - }], - }) - - expect(parsed.servers[0]?.headers).toEqual({ - 'X-Literal': 'literal-header', - 'X-Secret': 'header-secret', - 'X-Secret-File': 'header-file-secret', - }) - expect(parsed.peers[0]?.headers).toEqual({ - 'X-Peer-Secret': 'header-secret', - 'X-Peer-Secret-File': 'header-file-secret', - }) + test('returns undefined when already at the latest version', () => { + const result = migrateConfig({ version: MIGRATIONS.length }) + expect(result).toBeUndefined() }) - test('keeps the hex constraint for env-resolved server keys', () => { - process.env.BAD_HEX = 'too-short' - const result = ServerConfig.safeParse({ - name: 'radarr', - type: 'radarr', - url: 'http://radarr:7878', - apiKey: { env: 'BAD_HEX' }, - }) - expect(result.success).toBe(false) + test('returns undefined when the version is ahead of the known migrations', () => { + const result = migrateConfig({ version: MIGRATIONS.length + 5 }) + expect(result).toMatchObject({ version: 1 }) }) - test('requires a name on servers', () => { - const result = ServerConfig.safeParse({ - type: 'radarr', - url: 'http://radarr:7878', - apiKey: HEX_KEY, - }) - expect(result.success).toBe(false) - }) - - test('fails parsing when a referenced env var is missing', () => { - delete process.env.JACK_KEY - const result = JackConfig.safeParse({ baseUrl: 'http://jack:3000', apiKey: { env: 'JACK_KEY' } }) - expect(result.success).toBe(false) - }) - - test('fails parsing when a referenced header env var is missing', () => { - delete process.env.HEADER_SECRET - const result = PeerConfig.safeParse({ - name: 'friend', - url: 'http://peer:3000', - apiKey: 'peer-key', - headers: { 'X-Secret': { env: 'HEADER_SECRET' } }, - }) - expect(result.success).toBe(false) - }) - - test('defaults the downloads hardening knobs', () => { - const parsed = AppConfig.parse({ - downloads: { completedPath: '/c' }, - }) - expect(parsed.downloads).toMatchObject({ - maxConcurrentDownloads: 3, - maxDownloadAttempts: 13, - retryBaseDelayMs: 1000, - retryMaxDelayMs: 1_800_000, - idleTimeoutMs: 60_000, - }) - }) + test('applies only the migrations newer than the current version', () => { + // Build a fake migration chain so the test is independent of how many real + // migrations exist: each step stamps the version it produces. + const original = [...MIGRATIONS] + try { + MIGRATIONS.length = 0 + MIGRATIONS.push( + (obj: T) => ({ ...obj, version: 1, m1: true }), + (obj: T) => ({ ...obj, version: 2, m2: true }), + ) - test('respects an explicit maxConcurrentDownloads and rejects non-positive values', () => { - const parsed = AppConfig.parse({ downloads: { completedPath: '/c', maxConcurrentDownloads: 8 } }) - expect(parsed.downloads?.maxConcurrentDownloads).toBe(8) - expect(AppConfig.safeParse({ downloads: { completedPath: '/c', maxConcurrentDownloads: 0 } }).success).toBe(false) + // Starting at version 1, only the second migration should run. + const result = migrateConfig({ version: 1, kept: true }) as Record + expect(result).toMatchObject({ version: 2, kept: true, m2: true }) + expect(result.m1).toBeUndefined() + } + finally { + MIGRATIONS.length = 0 + MIGRATIONS.push(...original) + } }) }) diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index 1abc0ff..ad27d7b 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -159,6 +159,7 @@ export const DownloadsConfig = z.object({ export type DownloadsConfig = z.infer export const AppConfig = z.object({ + version: z.number(), jack: JackConfig.optional(), downloads: DownloadsConfig.optional(), servers: z.array(ServerConfig).default([]), @@ -167,11 +168,38 @@ export const AppConfig = z.object({ export type AppConfig = z.infer +export const MIGRATIONS = [ + (obj: T): T & { version: number } => ({ ...obj, version: 1 }), +] +const LATEST_MIGRATION = MIGRATIONS.length + +export function migrateConfig(rawConfigObject: unknown) { + const configObject = z + .looseObject({ version: z.number().max(LATEST_MIGRATION).min(0).default(0) }) + .catch({ version: 0 }) + .parse(rawConfigObject) + + const currentVersion = configObject.version + const migrationsToApply = MIGRATIONS.slice(currentVersion) + + if (migrationsToApply.length === 0) { + return + } + + logger.debug(`Migrating config from version ${currentVersion} to version ${MIGRATIONS.length}`) + + return migrationsToApply.reduce((acc, migration, idx) => { + logger.trace({ input: acc }, `Migrating to version ${idx + 1}`) + return migration(acc) + }, configObject) +} + // Template written to disk to bootstrap a fresh install. API keys default to the // `{ env: "..." }` form so secrets can be supplied via environment variables // instead of being hardcoded in the file. Typed as the schema *input* so the // env-reference shape is allowed here. const DEFAULT_APP_CONFIG: z.input = { + version: MIGRATIONS.length, jack: { baseUrl: 'http://jack:5225', apiKey: { env: 'JACK_API_KEY' }, @@ -183,6 +211,7 @@ const DEFAULT_APP_CONFIG: z.input = { // Fallback returned on first boot when the default's env references aren't set // yet, so the app keeps starting instead of crashing on a fresh install. const EMPTY_APP_CONFIG: AppConfig = { + version: MIGRATIONS.length, servers: [], peers: [], } @@ -215,6 +244,8 @@ export async function getAppConfig({ APP_CONFIG_PATH }: Pick Date: Wed, 10 Jun 2026 15:14:29 +0200 Subject: [PATCH 02/22] chore: bump typescript to 6 and enable noUnusedLocals/Parameters --- apps/backend/package.json | 2 +- bun.lock | 66 +++++++++++++++++++++++++++++++++++++-- tsconfig.json | 4 +-- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index f9fd824..c07153c 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -4,7 +4,7 @@ "private": true, "module": "index.ts", "peerDependencies": { - "typescript": "^5" + "typescript": "^6.0.3" }, "dependencies": { "@hono/otel": "1.1.2", diff --git a/bun.lock b/bun.lock index 0959bdb..0075370 100644 --- a/bun.lock +++ b/bun.lock @@ -49,7 +49,7 @@ "pino-pretty": "^13.1.3", }, "peerDependencies": { - "typescript": "^5", + "typescript": "^6.0.3", }, }, "packages/schemas": { @@ -213,8 +213,16 @@ "@jack/schemas": ["@jack/schemas@workspace:packages/schemas"], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], @@ -329,6 +337,8 @@ "@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/types": "^8.56.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0" } }, "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ=="], + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="], + "@total-typescript/ts-reset": ["@total-typescript/ts-reset@0.6.1", "", {}, "sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg=="], "@types/bencode": ["@types/bencode@2.0.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-sirDu3HUSG7jZMlhTDvCzSFiPR4lkUYBQA75CoMi6DEf2alFZWJWtHgfjBbb9PachPZhPMB1IlH09deyMNBipQ=="], @@ -351,6 +361,8 @@ "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/type-utils": "8.57.1", "@typescript-eslint/utils": "8.57.1", "@typescript-eslint/visitor-keys": "8.57.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ=="], @@ -407,8 +419,12 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], @@ -457,6 +473,8 @@ "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -501,6 +519,8 @@ "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + "devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], @@ -567,6 +587,8 @@ "eslint-plugin-regexp": ["eslint-plugin-regexp@3.1.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", "comment-parser": "^1.4.0", "jsdoc-type-pratt-parser": "^7.0.0", "refa": "^0.12.1", "regexp-ast-analysis": "^0.7.1", "scslre": "^0.3.0" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-qGXIC3DIKZHcK1H9A9+Byz9gmndY6TTSRkSMTZpNXdyCw2ObSehRgccJv35n9AdUakEjQp5VFNLas6BMXizCZg=="], + "eslint-plugin-svelte": ["eslint-plugin-svelte@3.19.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.7.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-t3rNaZeXz4d2gG4uJyMEYfJCFKf22+SWbSizIIXIWKu4wM+XPLiMWuSSr/C5821JmFeN9ogK+eExbG+z+twyxw=="], + "eslint-plugin-toml": ["eslint-plugin-toml@1.3.1", "", { "dependencies": { "@eslint/core": "^1.0.1", "@eslint/plugin-kit": "^0.6.0", "@ota-meshi/ast-token-store": "^0.3.0", "debug": "^4.1.1", "toml-eslint-parser": "^1.0.1" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-1l00fBP03HIt9IPV7ZxBi7x0y0NMdEZmakL1jBD6N/FoKBvfKxPw5S8XkmzBecOnFBTn5Z8sNJtL5vdf9cpRMQ=="], "eslint-plugin-unicorn": ["eslint-plugin-unicorn@63.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "@eslint-community/eslint-utils": "^4.9.0", "change-case": "^5.4.4", "ci-info": "^4.3.1", "clean-regexp": "^1.0.0", "core-js-compat": "^3.46.0", "find-up-simple": "^1.0.1", "globals": "^16.4.0", "indent-string": "^5.0.0", "is-builtin-module": "^5.0.0", "jsesc": "^3.1.0", "pluralize": "^8.0.0", "regexp-tree": "^0.1.27", "regjsparser": "^0.13.0", "semver": "^7.7.3", "strip-indent": "^4.1.1" }, "peerDependencies": { "eslint": ">=9.38.0" } }, "sha512-Iqecl9118uQEXYh7adylgEmGfkn5es3/mlQTLLkd4pXkIk9CTGrAbeUux+YljSa2ohXCBmQQ0+Ej1kZaFgcfkA=="], @@ -583,10 +605,14 @@ "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + "esrap": ["esrap@2.2.11", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ=="], + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], @@ -679,6 +705,8 @@ "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -707,10 +735,16 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + "local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="], + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], @@ -825,7 +859,7 @@ "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -899,7 +933,13 @@ "pnpm-workspace-yaml": ["pnpm-workspace-yaml@1.6.0", "", { "dependencies": { "yaml": "^2.8.2" } }, "sha512-uUy4dK3E11sp7nK+hnT7uAWfkBMe00KaUw8OG3NuNlYQoTk4sc9pcdIy1+XIP85v9Tvr02mK3JPaNNrP0QyRaw=="], - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="], + + "postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="], + + "postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], @@ -991,6 +1031,10 @@ "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "svelte": ["svelte@5.56.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.11", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA=="], + + "svelte-eslint-parser": ["svelte-eslint-parser@1.8.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg=="], + "synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], @@ -1077,6 +1121,8 @@ "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -1103,6 +1149,8 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "@vue/compiler-sfc/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "clean-regexp/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -1111,6 +1159,8 @@ "eslint-plugin-n/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + "eslint-plugin-unicorn/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "eslint-plugin-yml/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -1125,6 +1175,14 @@ "nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], + "postcss-load-config/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + + "svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "svelte-eslint-parser/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], @@ -1173,6 +1231,8 @@ "@jack/schemas/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "@vue/compiler-sfc/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], diff --git a/tsconfig.json b/tsconfig.json index dc421e5..d858c6e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,8 +21,8 @@ "noPropertyAccessFromIndexSignature": false, "noUncheckedIndexedAccess": true, // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, + "noUnusedLocals": true, + "noUnusedParameters": true, "noEmit": true, "verbatimModuleSyntax": true, "skipLibCheck": true From 1fcde963de45974d4703f33328e9d1f8e43cff9f Mon Sep 17 00:00:00 2001 From: Roz Date: Wed, 10 Jun 2026 15:14:29 +0200 Subject: [PATCH 03/22] docs: note ai_docs is gitignored in AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 0473928..b4d93c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ test('hello world', () => { ## Git - Use Conventional Commits for commit messages, e.g. `feat: add peer search spans` or `fix: handle missing torrent files`. +- `ai_docs/` is gitignored. Don't worry about git state for changes under `ai_docs/`, and don't try to commit them. ## Frontend From 04a925312597d8bcded7f64ffc173314ab0ac4ce Mon Sep 17 00:00:00 2001 From: Roz Date: Thu, 11 Jun 2026 00:34:52 +0200 Subject: [PATCH 04/22] feat: redact sensitive fields in logs --- apps/backend/src/lib/__tests__/redact.test.ts | 45 +++++++++++++++++++ apps/backend/src/lib/redact.ts | 33 ++++++++++++++ apps/backend/src/logger.ts | 8 ++++ 3 files changed, 86 insertions(+) create mode 100644 apps/backend/src/lib/__tests__/redact.test.ts diff --git a/apps/backend/src/lib/__tests__/redact.test.ts b/apps/backend/src/lib/__tests__/redact.test.ts new file mode 100644 index 0000000..d75527b --- /dev/null +++ b/apps/backend/src/lib/__tests__/redact.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test' +import { REDACTED, redactObject } from '../redact' + +describe('redactObject', () => { + test('masks string values under sensitive keys, keeping edges', () => { + const result = redactObject({ authorization: 'Bearer super-secret-token-value' }) + expect(result.authorization).toBe('Bear…alue') + }) + + test('fully redacts sensitive strings too short to mask meaningfully', () => { + expect(redactObject({ token: 'short' }).token).toBe(REDACTED) + }) + + test('leaves non-sensitive fields untouched', () => { + const input = { userId: 42, action: 'login', nested: { count: 1 } } + expect(redactObject(input)).toEqual(input) + }) + + test('recurses into nested objects and arrays', () => { + const result = redactObject({ + request: { + headers: { cookie: 'session=abcdefghijklmnop' }, + body: { ok: true }, + }, + items: [{ apiKey: 'abcdefghijklmnop' }, { id: 7 }], + }) + expect(result).toEqual({ + request: { + headers: { cookie: 'sess…mnop' }, + body: { ok: true }, + }, + items: [{ apiKey: 'abcd…mnop' }, { id: 7 }], + }) + }) + + test('hides non-string values under sensitive keys entirely', () => { + expect(redactObject>({ password: { hash: 'x' } }).password).toBe(REDACTED) + expect(redactObject>({ secret: 12345 }).secret).toBe(REDACTED) + }) + + test('masks each element of a sensitive array', () => { + const result = redactObject({ token: ['abcdefghijklmnop', 'short'] }) + expect(result.token).toEqual(['abcd…mnop', REDACTED]) + }) +}) diff --git a/apps/backend/src/lib/redact.ts b/apps/backend/src/lib/redact.ts index 31193a6..360c967 100644 --- a/apps/backend/src/lib/redact.ts +++ b/apps/backend/src/lib/redact.ts @@ -31,3 +31,36 @@ export function redactRecord(record: Record): Record< Object.entries(record).map(([key, value]) => [key, redactIfSensitive(key, value)]), ) } + +// Mask a value that lives under a sensitive key, whatever its shape: strings get +// the edge-preserving mask, arrays are masked element-wise, and anything else +// (numbers, nested objects) is hidden entirely since we can't safely show any of it. +function maskSensitiveValue(value: unknown): unknown { + if (typeof value === 'string') { + return redactValue(value) + } + if (Array.isArray(value)) { + return value.map(maskSensitiveValue) + } + if (value === null || value === undefined) { + return value + } + return REDACTED +} + +// Recursively redact sensitive fields anywhere in an arbitrary value, leaving the +// surrounding structure intact. Used to scrub log records before they're emitted. +export function redactObject(value: T): T { + if (Array.isArray(value)) { + return value.map(item => redactObject(item)) as T + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, val]) => [ + key, + isSensitiveField(key) ? maskSensitiveValue(val) : redactObject(val), + ]), + ) as T + } + return value +} diff --git a/apps/backend/src/logger.ts b/apps/backend/src/logger.ts index 5fe4e94..2c6fb34 100644 --- a/apps/backend/src/logger.ts +++ b/apps/backend/src/logger.ts @@ -4,6 +4,7 @@ import { trace } from '@opentelemetry/api' import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { levels, multistream, pino } from 'pino' import { getAppEnvs, isOtelEnabled } from './lib/envs' +import { redactObject } from './lib/redact' const envs = getAppEnvs() const otelEnabled = isOtelEnabled(envs) @@ -22,6 +23,13 @@ const logFormatters = { level(label: string, level: number) { return { level, severity: label } }, + // Runs on the fully-merged record just before serialization, so it scrubs + // sensitive values regardless of where they entered the log (bindings, mixin, + // or the logged object itself) — something a mixin can't do, since its fields + // are overridden by the logged object rather than the other way around. + log(object: Record) { + return redactObject(object) + }, } // Tie logs to traces: stamp each log with the active span's ids. Runs in the From 0f37496aae9d8f0fc715ab5ae25a9ac04d7436f7 Mon Sep 17 00:00:00 2001 From: Roz Date: Thu, 11 Jun 2026 00:34:52 +0200 Subject: [PATCH 05/22] feat: route span attributes through a redacting funnel --- .../src/lib/__tests__/span-attributes.test.ts | 91 ++++++++++++++ apps/backend/src/lib/servers/arr/radarr.ts | 9 +- apps/backend/src/lib/servers/arr/sonarr.ts | 5 +- apps/backend/src/lib/servers/base.ts | 16 +-- apps/backend/src/lib/servers/peer.ts | 27 ++-- apps/backend/src/lib/span-attributes.ts | 119 ++++++++++++++++++ apps/backend/src/lib/tracing.ts | 15 +-- apps/backend/src/middleware/log-requests.ts | 54 ++++---- .../src/modules/items/items.controller.ts | 7 +- .../src/modules/peer/peer.controller.ts | 25 ++-- .../src/modules/torznab/torznab.controller.ts | 7 +- eslint.config.mjs | 31 +++-- 12 files changed, 322 insertions(+), 84 deletions(-) create mode 100644 apps/backend/src/lib/__tests__/span-attributes.test.ts create mode 100644 apps/backend/src/lib/span-attributes.ts diff --git a/apps/backend/src/lib/__tests__/span-attributes.test.ts b/apps/backend/src/lib/__tests__/span-attributes.test.ts new file mode 100644 index 0000000..de609d0 --- /dev/null +++ b/apps/backend/src/lib/__tests__/span-attributes.test.ts @@ -0,0 +1,91 @@ +import type { Attributes, AttributeValue } from '@opentelemetry/api' +import { describe, expect, test } from 'bun:test' +import { REDACTED } from '../redact' +import { redactUrl, sanitizeAttributes, setSpanAttribute } from '../span-attributes' + +// Minimal span stub that records what setSpanAttribute writes. +function fakeSpan() { + const attributes: Attributes = {} + return { + attributes, + setAttribute(key: string, value: AttributeValue) { + attributes[key] = value + return this + }, + } +} + +function setOne(key: string, value: unknown): AttributeValue | undefined { + const span = fakeSpan() + setSpanAttribute(span as never, key, value) + return span.attributes[key] +} + +describe('setSpanAttribute', () => { + test('passes scalars through untouched', () => { + expect(setOne('release.count', 42)).toBe(42) + expect(setOne('range.satisfiable', true)).toBe(true) + expect(setOne('url.path', '/torznab/api')).toBe('/torznab/api') + }) + + test('skips null/undefined entirely', () => { + const span = fakeSpan() + setSpanAttribute(span as never, 'a', undefined) + setSpanAttribute(span as never, 'b', null) + expect(Object.keys(span.attributes)).toHaveLength(0) + }) + + test('masks a string under a sensitive key', () => { + expect(setOne('http.request.header.authorization', 'Bearer super-secret-token')).toBe('Bear…oken') + }) + + test('masks each element of a sensitive string array', () => { + expect(setOne('x-api-key', ['abcdefghijklmnop', 'short'])).toEqual(['abcd…mnop', REDACTED]) + }) + + test('serializes objects to JSON with nested fields redacted', () => { + const value = setOne('http.request.headers', { 'content-type': 'application/json', 'authorization': 'abcdefghijklmnop' }) + expect(value).toBe(JSON.stringify({ 'content-type': 'application/json', 'authorization': 'abcd…mnop' })) + }) + + test('fully redacts an object that sits under a sensitive key', () => { + expect(setOne('authorization', { scheme: 'Bearer', value: 'x' })).toBe(REDACTED) + }) + + test('truncates oversized strings', () => { + const big = 'a'.repeat(10_000) + const value = setOne('http.response.body', big) as string + expect(value.length).toBe(8 * 1024 + 1) // capped slice + ellipsis + expect(value.endsWith('…')).toBe(true) + }) +}) + +describe('sanitizeAttributes', () => { + test('sanitizes a record and drops undefined-valued keys', () => { + const result = sanitizeAttributes({ + 'connector.name': 'sonarr', + 'http.request.headers': { authorization: 'abcdefghijklmnop' }, + 'url.query': undefined, + }) + expect(result).toEqual({ + 'connector.name': 'sonarr', + 'http.request.headers': JSON.stringify({ authorization: 'abcd…mnop' }), + }) + }) +}) + +describe('redactUrl', () => { + test('returns the input unchanged when no query param is sensitive', () => { + const url = 'https://tracker.test/api?t=search&q=dune&season=1' + expect(redactUrl(url)).toBe(url) + }) + + test('masks only sensitive param values, preserving order and the rest', () => { + const result = redactUrl('https://tracker.test/api?t=search&apikey=abcdefghijklmnop&q=dune') + expect(result).toBe('https://tracker.test/api?t=search&apikey=abcd%E2%80%A6mnop&q=dune') + }) + + test('leaves a non-URL string untouched', () => { + expect(redactUrl('not a url')).toBe('not a url') + }) +}) diff --git a/apps/backend/src/lib/servers/arr/radarr.ts b/apps/backend/src/lib/servers/arr/radarr.ts index 8d16650..dd9de37 100644 --- a/apps/backend/src/lib/servers/arr/radarr.ts +++ b/apps/backend/src/lib/servers/arr/radarr.ts @@ -2,6 +2,7 @@ import type { MovieFileResource, MovieResource } from '@jack/schemas/radarr/type import type { AutoRegisterConfig, ConnectorHeadersConfig } from '../../config' import type { Release } from '../../release' import { normalizeImdbId, ReleaseCategory } from '../../release' +import { setSpanAttribute, setSpanAttributes } from '../../span-attributes' import { withSpan } from '../../tracing' import { ArrServerConnector, basename, stripExtension } from './base' @@ -72,7 +73,7 @@ export class RadarrServerConnector extends ArrServerConnector { .filter(m => m.hasFile && (!needle || (m.title ?? '').toLowerCase().includes(needle))) .map(m => this.toRelease(m)) .filter((r): r is Release => r != null) - span.setAttributes({ 'movie.count': movies.length, 'movie.with_file_count': withFile, 'release.count': releases.length }) + setSpanAttributes(span, { 'movie.count': movies.length, 'movie.with_file_count': withFile, 'release.count': releases.length }) return releases }) } @@ -91,9 +92,9 @@ export class RadarrServerConnector extends ArrServerConnector { .filter(m => m.imdbId != null && normalizeImdbId(m.imdbId) === target) .map(m => this.toRelease(m)) .filter((r): r is Release => r != null) - span.setAttributes({ 'movie.count': movies.length, 'movie.with_file_count': withFileMovies.length, 'release.count': releases.length }) + setSpanAttributes(span, { 'movie.count': movies.length, 'movie.with_file_count': withFileMovies.length, 'release.count': releases.length }) if (releases.length === 0) { - span.setAttribute('search.sample_imdb_ids', withFileMovies.map(m => m.imdbId).filter((id): id is string => !!id).slice(0, 10)) + setSpanAttribute(span, 'search.sample_imdb_ids', withFileMovies.map(m => m.imdbId).filter((id): id is string => !!id).slice(0, 10)) } return releases }) @@ -111,7 +112,7 @@ export class RadarrServerConnector extends ArrServerConnector { .filter(m => m.hasFile) .map(m => this.toRelease(m)) .filter((r): r is Release => r != null) - span.setAttribute('release.count', releases.length) + setSpanAttribute(span, 'release.count', releases.length) return releases }) } diff --git a/apps/backend/src/lib/servers/arr/sonarr.ts b/apps/backend/src/lib/servers/arr/sonarr.ts index b1e52c6..22f5018 100644 --- a/apps/backend/src/lib/servers/arr/sonarr.ts +++ b/apps/backend/src/lib/servers/arr/sonarr.ts @@ -2,6 +2,7 @@ import type { EpisodeFileResource, EpisodeResource, SeriesResource } from '@jack import type { AutoRegisterConfig, ConnectorHeadersConfig } from '../../config' import type { Release } from '../../release' import { ReleaseCategory } from '../../release' +import { setSpanAttributes } from '../../span-attributes' import { withSpan } from '../../tracing' import { ArrServerConnector, basename, stripExtension } from './base' @@ -90,7 +91,7 @@ export class SonarrServerConnector extends ArrServerConnector { const matching = series.filter(s => !needle || (s.title ?? '').toLowerCase().includes(needle)) const perSeries = await Promise.all(matching.map(s => this.releasesForSeries(s))) const releases = perSeries.flat() - span.setAttributes({ 'series.count': series.length, 'series.matched_count': matching.length, 'release.count': releases.length }) + setSpanAttributes(span, { 'series.count': series.length, 'series.matched_count': matching.length, 'release.count': releases.length }) return releases }) } @@ -127,7 +128,7 @@ export class SonarrServerConnector extends ArrServerConnector { return true }))) const releases = perSeries.flat() - span.setAttributes({ 'series.matched_count': series.length, 'release.count': releases.length }) + setSpanAttributes(span, { 'series.matched_count': series.length, 'release.count': releases.length }) return releases }) } diff --git a/apps/backend/src/lib/servers/base.ts b/apps/backend/src/lib/servers/base.ts index 7f33c19..b11aa6e 100644 --- a/apps/backend/src/lib/servers/base.ts +++ b/apps/backend/src/lib/servers/base.ts @@ -3,7 +3,7 @@ import z from 'zod' import { logger } from '../../logger' import { getAppEnvs } from '../envs' import { FetchError } from '../errors/FetchError' -import { redactRecord } from '../redact' +import { setSpanAttribute, setSpanAttributes } from '../span-attributes' import { withSpan } from '../tracing' const DEFAULT_FETCH_TIMEOUT_MS = getAppEnvs().HTTP_TIMEOUT_MS @@ -103,7 +103,7 @@ export abstract class ServerConnector { 'connector.type': this.type, 'http.request.method': method, 'http.request.timeout_ms': timeoutMs, - 'http.request.headers': JSON.stringify(redactRecord(initWithAuth.headers)), + 'http.request.headers': initWithAuth.headers, 'server.address': url.hostname, 'url.path': url.pathname, 'url.query': url.search ? url.search.slice(1) : undefined, @@ -114,12 +114,12 @@ export abstract class ServerConnector { } catch (err) { const timedOut = err instanceof DOMException && err.name === 'TimeoutError' - span.setAttribute('error.timeout', timedOut) + setSpanAttribute(span, 'error.timeout', timedOut) logger.warn({ connector: this.name, method, url: url.toString(), timeoutMs, timedOut, err }, timedOut ? `Request timed out after ${timeoutMs}ms` : 'Request failed (network error)') throw err } - span.setAttributes({ + setSpanAttributes(span, { 'http.response.status_code': response.status, 'http.response.content_type': response.headers.get('content-type') ?? '', 'http.response.content_length': response.headers.get('content-length') ?? '', @@ -127,7 +127,7 @@ export abstract class ServerConnector { if (!response.ok) { const body = await response.text().catch(() => 'Could not fetch body') - span.setAttribute('http.response.body', truncateBody(body)) + setSpanAttribute(span, 'http.response.body', body) logger.warn({ connector: this.name, method, url: url.toString(), status: response.status, body: truncateBody(body) }, 'Request failed (non-2xx)') throw new FetchError(`Failed to fetch url: ${response.statusText}`, response, { body, method: init.method, headers: initWithAuth.headers }) } @@ -141,7 +141,7 @@ export abstract class ServerConnector { if (!success) { const prettyError = z.prettifyError(error) - span.setAttributes({ + setSpanAttributes(span, { 'schema.validation.success': false, 'schema.validation.error': prettyError, }) @@ -149,7 +149,7 @@ export abstract class ServerConnector { throw new FetchError(`Invalid response from ${this.name} when fetching ${init.method ?? 'GET'} ${url.pathname}: ${prettyError}`, response, { body: JSON.stringify(body), method: init.method }) } - span.setAttribute('schema.validation.success', true) + setSpanAttribute(span, 'schema.validation.success', true) return data }) } @@ -196,7 +196,7 @@ export abstract class ServerConnector { 'init.previous_error': previousError, }, async (span) => { await this.runInit() - span.setAttribute('connector.initialized', true) + setSpanAttribute(span, 'connector.initialized', true) }) .then(() => { this._isInitialized = true diff --git a/apps/backend/src/lib/servers/peer.ts b/apps/backend/src/lib/servers/peer.ts index 4ac8c44..ff69872 100644 --- a/apps/backend/src/lib/servers/peer.ts +++ b/apps/backend/src/lib/servers/peer.ts @@ -9,6 +9,7 @@ import { IncompatiblePeerError } from '../errors/IncompatiblePeerError' import { IncompleteDownloadError } from '../errors/IncompleteDownloadError' import { UnknownSizeError } from '../errors/UnknownSizeError' import { normalizeImdbId, Release } from '../release' +import { setSpanAttribute, setSpanAttributes } from '../span-attributes' import { withSpan } from '../tracing' import { isPeerVersionCompatible, MIN_PEER_PROTOCOL_VERSION } from '../version' import { ServerConnector } from './base' @@ -115,7 +116,7 @@ export class PeerConnector extends ServerConnector { } this._peerVersion = version - span.setAttributes({ 'peer.version': version, 'peer.initialized': true }) + setSpanAttributes(span, { 'peer.version': version, 'peer.initialized': true }) logger.debug({ peer: this.name, version }, `Connected to Jack peer ${this.name}`) }) } @@ -132,7 +133,7 @@ export class PeerConnector extends ServerConnector { // whole catalog), so keep only the releases that actually match the id. const target = normalizeImdbId(imdbId) const matched = items.filter(r => r.imdbId != null && normalizeImdbId(r.imdbId) === target) - span.setAttributes({ 'release.returned_count': items.length, 'release.matched_count': matched.length }) + setSpanAttributes(span, { 'release.returned_count': items.length, 'release.matched_count': matched.length }) return matched }) } @@ -146,7 +147,7 @@ export class PeerConnector extends ServerConnector { }, async (span) => { const { items } = await this.fetch('/peer/search', { method: 'GET', query: { tmdbId }, schema: PeerSearchResponse }) const matched = items.filter(r => r.tmdbId != null && String(r.tmdbId) === tmdbId) - span.setAttributes({ 'release.returned_count': items.length, 'release.matched_count': matched.length }) + setSpanAttributes(span, { 'release.returned_count': items.length, 'release.matched_count': matched.length }) return matched }) } @@ -159,7 +160,7 @@ export class PeerConnector extends ServerConnector { 'peer.id': this.id, }, async (span) => { const { items } = await this.fetch('/peer/search', { method: 'GET', schema: PeerSearchResponse }) - span.setAttribute('release.count', items.length) + setSpanAttribute(span, 'release.count', items.length) return items }) } @@ -183,7 +184,7 @@ export class PeerConnector extends ServerConnector { r.tvdbId != null && String(r.tvdbId) === tvdbId && (season == null || r.season === season) && (episode == null || r.episode === episode)) - span.setAttributes({ 'release.returned_count': items.length, 'release.matched_count': matched.length }) + setSpanAttributes(span, { 'release.returned_count': items.length, 'release.matched_count': matched.length }) return matched }) } @@ -206,7 +207,7 @@ export class PeerConnector extends ServerConnector { const url = new URL(`/peer/items/${encodeURIComponent(id)}/file`, this.url) const partPath = options.partPath ?? `${destPath}.part` const baseHeaders = { ...this.headers, 'X-Api-Key': this.apiKey } - span.setAttributes({ 'http.request.idle_timeout_ms': idleTimeoutMs, 'url.path': url.pathname }) + setSpanAttributes(span, { 'http.request.idle_timeout_ms': idleTimeoutMs, 'url.path': url.pathname }) // Idle (inactivity) timeout, armed ONLY around network waits (fetch + each // read) and cleared before local file/progress work, so slow disk I/O never @@ -283,7 +284,7 @@ export class PeerConnector extends ServerConnector { } else if (existingBytes === options.releaseSize) { await rename(partPath, destPath) - span.setAttribute('download.downloaded_bytes', existingBytes) + setSpanAttribute(span, 'download.downloaded_bytes', existingBytes) // Emit headers too so the service persists expectedBytes/source (the // fast path otherwise skips the headers event). await options.onProgress?.({ type: 'headers', expectedBytes: options.releaseSize, expectedBytesSource: 'release_size', expectedBytesMismatch: false }) @@ -293,7 +294,7 @@ export class PeerConnector extends ServerConnector { } let response = await doFetch(existingBytes > 0) - span.setAttribute('http.response.status_code', response.status) + setSpanAttribute(span, 'http.response.status_code', response.status) if (existingBytes > 0) { if (response.status === 206) { @@ -302,12 +303,12 @@ export class PeerConnector extends ServerConnector { && (options.releaseSize == null || cr.total === options.releaseSize) if (!valid) { response = await restartFresh(response, 'content_range_mismatch', existingBytes) - span.setAttribute('http.response.status_code', response.status) + setSpanAttribute(span, 'http.response.status_code', response.status) } } else if (response.status === 416) { response = await restartFresh(response, 'range_not_satisfiable', existingBytes) - span.setAttribute('http.response.status_code', response.status) + setSpanAttribute(span, 'http.response.status_code', response.status) } else if (!response.ok) { throw new FetchError(`Failed to resume download from peer: ${response.statusText}`, response) @@ -353,7 +354,7 @@ export class PeerConnector extends ServerConnector { const expectedBytesSource: 'content_length' | 'content_range' | 'release_size' | null = transferSize != null ? (resuming ? 'content_range' : 'content_length') : (expectedBytes != null ? 'release_size' : null) const expectedBytesMismatch = transferSize != null && options.releaseSize != null && transferSize !== options.releaseSize - span.setAttributes({ + setSpanAttributes(span, { 'download.resuming': resuming, 'download.resume_from_bytes': existingBytes, 'download.expected_bytes_source': expectedBytesSource ?? 'unknown', @@ -364,7 +365,7 @@ export class PeerConnector extends ServerConnector { void response.body.cancel().catch(() => {}) throw new UnknownSizeError(`Cannot verify download for item ${id}: no Content-Length/Content-Range and no release size`) } - span.setAttribute('download.expected_bytes', expectedBytes) + setSpanAttribute(span, 'download.expected_bytes', expectedBytes) if (expectedBytesMismatch) { logger.warn({ id, torrentFilename, releaseSize: options.releaseSize, expectedBytes: transferSize, peer: this.name }, 'Peer file total size differs from release metadata size') @@ -449,7 +450,7 @@ export class PeerConnector extends ServerConnector { reader.releaseLock() await rename(partPath, destPath) - span.setAttribute('download.downloaded_bytes', downloadedBytes) + setSpanAttribute(span, 'download.downloaded_bytes', downloadedBytes) try { await options.onProgress?.({ type: 'completed', downloadedBytes, expectedBytes }) } diff --git a/apps/backend/src/lib/span-attributes.ts b/apps/backend/src/lib/span-attributes.ts new file mode 100644 index 0000000..0584099 --- /dev/null +++ b/apps/backend/src/lib/span-attributes.ts @@ -0,0 +1,119 @@ +import type { Attributes, AttributeValue, Span } from '@opentelemetry/api' +import { isSensitiveField, REDACTED, redactObject, redactValue } from './redact' + +// The single funnel for putting data on a span. Every attribute the app sets +// goes through here so redaction, serialization, and truncation happen in one +// place — call sites never touch `span.setAttribute` directly (a lint rule +// enforces this). OTel only accepts primitives and homogeneous primitive arrays +// as attribute values, so anything richer is redacted and JSON-serialized here. + +// Final guard on attribute size. Distinct from the capture-time body cap in the +// request logger (which bounds memory while reading a stream); this one bounds +// the serialized attribute regardless of where the value came from. +const MAX_ATTRIBUTE_VALUE_LENGTH = 8 * 1024 + +function capString(value: string): string { + return value.length <= MAX_ATTRIBUTE_VALUE_LENGTH + ? value + : `${value.slice(0, MAX_ATTRIBUTE_VALUE_LENGTH)}…` +} + +function maskString(key: string, value: string): string { + return capString(isSensitiveField(key) ? redactValue(value) : value) +} + +// Turn an arbitrary value into something OTel can store, redacting on the way. +// Returns undefined when there's nothing to set (so the attribute is skipped). +function sanitizeAttributeValue(key: string, value: unknown): AttributeValue | undefined { + if (value === undefined || value === null) { + return undefined + } + if (typeof value === 'number' || typeof value === 'boolean') { + return value + } + if (typeof value === 'string') { + return maskString(key, value) + } + if (Array.isArray(value)) { + // Homogeneous primitive arrays are valid attribute values as-is. + if (value.every(item => typeof item === 'string')) { + return value.map(item => maskString(key, item)) + } + if (value.every(item => typeof item === 'number') || value.every(item => typeof item === 'boolean')) { + return value as number[] | boolean[] + } + // Otherwise (objects, mixed) fall through to JSON serialization below. + } + // Objects / complex arrays: a sensitive key means the whole thing is secret; + // anything else gets deep field-level redaction before serialization. + if (isSensitiveField(key)) { + return REDACTED + } + return capString(JSON.stringify(redactObject(value))) +} + +/** + * Set a single span attribute, redacting/serializing/truncating its value. + * Use this instead of `span.setAttribute`. + */ +export function setSpanAttribute(span: Span, key: string, value: unknown): void { + const sanitized = sanitizeAttributeValue(key, value) + if (sanitized === undefined) { + return + } + + span.setAttribute(key, sanitized) +} + +/** + * Set many span attributes at once. Use this instead of `span.setAttributes`. + */ +export function setSpanAttributes(span: Span, record: Record): void { + for (const [key, value] of Object.entries(record)) { + setSpanAttribute(span, key, value) + } +} + +/** + * Sanitize an attribute record for use at span *creation* time (where there's no + * span handle yet to call setSpanAttribute on), e.g. the attributes passed to + * `withSpan`. Drops keys whose value sanitizes to nothing. + */ +export function sanitizeAttributes(record: Record): Attributes { + const result: Attributes = {} + for (const [key, value] of Object.entries(record)) { + const sanitized = sanitizeAttributeValue(key, value) + if (sanitized !== undefined) { + result[key] = sanitized + } + } + return result +} + +/** + * Mask only sensitive *query-parameter values* in a URL, leaving scheme, host, + * path, and non-sensitive params intact so the URL stays debuggable. Returns the + * input unchanged (same reference semantics — identical string) when there's + * nothing sensitive to mask, so callers can skip writing when nothing changed. + */ +export function redactUrl(rawUrl: string): string { + let url: URL + try { + url = new URL(rawUrl) + } + catch { + return rawUrl + } + + const entries = [...url.searchParams.entries()] + if (!entries.some(([key]) => isSensitiveField(key))) { + return rawUrl + } + + // Rebuild the query in place so parameter order is preserved. + url.search = '' + for (const [key, value] of entries) { + url.searchParams.append(key, isSensitiveField(key) ? redactValue(value) : value) + } + return url.toString() +} diff --git a/apps/backend/src/lib/tracing.ts b/apps/backend/src/lib/tracing.ts index 2d87320..d98c772 100644 --- a/apps/backend/src/lib/tracing.ts +++ b/apps/backend/src/lib/tracing.ts @@ -1,16 +1,13 @@ -import type { Attributes, AttributeValue, Span } from '@opentelemetry/api' +import type { Span } from '@opentelemetry/api' import { SpanStatusCode, trace } from '@opentelemetry/api' +import { sanitizeAttributes } from './span-attributes' -type SpanAttributes = Record +// Values are unknown because they're sanitized (redacted/serialized) at creation +// time by `sanitizeAttributes` — the same funnel `setSpanAttribute` uses. +type SpanAttributes = Record const tracer = trace.getTracer('jack-backend') -function definedAttributes(attributes: SpanAttributes = {}): Attributes { - return Object.fromEntries( - Object.entries(attributes).filter((entry): entry is [string, AttributeValue] => entry[1] !== undefined), - ) -} - function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err) } @@ -25,7 +22,7 @@ export async function withSpan( attributes: SpanAttributes, fn: (span: Span) => Promise | T, ): Promise { - return tracer.startActiveSpan(name, { attributes: definedAttributes(attributes) }, async (span) => { + return tracer.startActiveSpan(name, { attributes: sanitizeAttributes(attributes) }, async (span) => { try { const result = await fn(span) span.setStatus({ code: SpanStatusCode.OK }) diff --git a/apps/backend/src/middleware/log-requests.ts b/apps/backend/src/middleware/log-requests.ts index 3e505a3..ab7a896 100644 --- a/apps/backend/src/middleware/log-requests.ts +++ b/apps/backend/src/middleware/log-requests.ts @@ -2,7 +2,7 @@ import type { AttributeValue, Span } from '@opentelemetry/api' import type { Context } from 'hono' import { trace } from '@opentelemetry/api' import { createMiddleware } from 'hono/factory' -import { redactIfSensitive, redactRecord } from '../lib/redact' +import { redactUrl, setSpanAttribute, setSpanAttributes } from '../lib/span-attributes' import { logger } from '../logger' const MAX_CAPTURED_BODY_BYTES = 8 * 1024 @@ -65,16 +65,15 @@ function isTextualContentType(contentType: string) { || normalized.includes('form-urlencoded') } -function jsonAttribute(value: unknown): string { - return JSON.stringify(value) -} - -function flattenedAttributes(prefix: string, record: Record, allowedFields: Set): Record { - return Object.fromEntries( - Object.entries(record) - .filter(([key]) => allowedFields.has(key.toLowerCase())) - .map(([key, value]) => [`${prefix}.${key.toLowerCase()}`, redactIfSensitive(key, value)]), - ) +// Promote an allowlisted subset of a header/query map to individual span +// attributes (e.g. `http.request.header.user-agent`). Redaction is handled by +// setSpanAttribute via the per-field key. +function setFlattenedAttributes(span: Span, prefix: string, record: Record, allowedFields: Set): void { + for (const [key, value] of Object.entries(record)) { + if (allowedFields.has(key.toLowerCase())) { + setSpanAttribute(span, `${prefix}.${key.toLowerCase()}`, value) + } + } } function emptyBody(size: string): CapturedBody { @@ -189,31 +188,40 @@ function bodyAttributes(prefix: string, body: CapturedBody | undefined): Record< async function addHttpSpanAttributes(span: Span, ctx: Context, durationMs: number, requestBody: CapturedBody | undefined) { const url = new URL(ctx.req.url) const responseBody = await captureResponseBody(ctx) - const requestHeaders = redactRecord(ctx.req.header()) - const requestQuery = redactRecord(queryToRecord(ctx.req.url)) - const responseHeaders = redactRecord(headersToRecord(ctx.res.headers)) + // Raw records: setSpanAttributes redacts, serializes, and truncates them. + const requestHeaders = ctx.req.header() + const requestQuery = queryToRecord(ctx.req.url) + const responseHeaders = headersToRecord(ctx.res.headers) - span.setAttributes({ + setSpanAttributes(span, { 'http.request.method': ctx.req.method, 'http.request.path': ctx.req.path, - 'http.request.url': ctx.req.url, - 'http.request.query': jsonAttribute(requestQuery), - 'http.request.headers': jsonAttribute(requestHeaders), + 'http.request.url': redactUrl(ctx.req.url), + 'http.request.query': requestQuery, + 'http.request.headers': requestHeaders, 'http.request.content_type': ctx.req.header('content-type') ?? '', 'http.request.content_length': ctx.req.header('content-length') ?? '', 'http.response.status_code': ctx.res.status, - 'http.response.headers': jsonAttribute(responseHeaders), + 'http.response.headers': responseHeaders, 'http.response.content_type': ctx.res.headers.get('content-type') ?? '', 'http.response.content_length': ctx.res.headers.get('content-length') ?? '', 'http.server.duration_ms': durationMs, 'url.path': url.pathname, - 'url.query': jsonAttribute(requestQuery), - ...flattenedAttributes('http.request.header', requestHeaders, FLATTENED_HEADER_FIELDS), - ...flattenedAttributes('http.request.query', requestQuery, FLATTENED_QUERY_FIELDS), - ...flattenedAttributes('http.response.header', responseHeaders, FLATTENED_HEADER_FIELDS), + 'url.query': requestQuery, ...bodyAttributes('http.request', requestBody), ...bodyAttributes('http.response', responseBody), }) + + setFlattenedAttributes(span, 'http.request.header', requestHeaders, FLATTENED_HEADER_FIELDS) + setFlattenedAttributes(span, 'http.request.query', requestQuery, FLATTENED_QUERY_FIELDS) + setFlattenedAttributes(span, 'http.response.header', responseHeaders, FLATTENED_HEADER_FIELDS) + + // @hono/otel sets `url.full` to the raw request URL; override it only when the + // query actually carried something sensitive (otherwise leave its value as-is). + const redactedFullUrl = redactUrl(ctx.req.url) + if (redactedFullUrl !== ctx.req.url) { + setSpanAttribute(span, 'url.full', redactedFullUrl) + } } export const logRequests = createMiddleware(async (ctx, next) => { diff --git a/apps/backend/src/modules/items/items.controller.ts b/apps/backend/src/modules/items/items.controller.ts index 0004922..e9fa619 100644 --- a/apps/backend/src/modules/items/items.controller.ts +++ b/apps/backend/src/modules/items/items.controller.ts @@ -1,4 +1,5 @@ import type { ArrServerConnector } from '../../lib/servers/arr/base' +import { setSpanAttribute } from '../../lib/span-attributes' import { withSpan } from '../../lib/tracing' import { logger } from '../../logger' @@ -17,7 +18,7 @@ export class ItemsController { // and re-initialized lazily by @requireInitialization, isolated per-source. const sources = this.connectors.sources.filter(c => c.canSource) if (sources.length === 0) { - span.setAttribute('source.result_count', 0) + setSpanAttribute(span, 'source.result_count', 0) return [] } @@ -29,7 +30,7 @@ export class ItemsController { 'search.term': searchTerm, }, async (sourceSpan) => { const items = await c.searchItems(searchTerm) - sourceSpan.setAttribute('item.count', items.length) + setSpanAttribute(sourceSpan, 'item.count', items.length) return { name: c.name, items } }) } @@ -39,7 +40,7 @@ export class ItemsController { } })) - span.setAttribute('source.result_count', results.length) + setSpanAttribute(span, 'source.result_count', results.length) return results }) } diff --git a/apps/backend/src/modules/peer/peer.controller.ts b/apps/backend/src/modules/peer/peer.controller.ts index 7bb3f50..7bff3e1 100644 --- a/apps/backend/src/modules/peer/peer.controller.ts +++ b/apps/backend/src/modules/peer/peer.controller.ts @@ -1,5 +1,6 @@ import type { Release } from '../../lib/release' import type { ArrServerConnector } from '../../lib/servers/arr/base' +import { setSpanAttribute, setSpanAttributes } from '../../lib/span-attributes' import { withSpan } from '../../lib/tracing' import { logger } from '../../logger' @@ -69,7 +70,7 @@ export class PeerController { const sources = this.sourceServers if (sources.length === 0) { - span.setAttribute('release.count', 0) + setSpanAttribute(span, 'release.count', 0) return [] } @@ -90,7 +91,7 @@ export class PeerController { : params.tvdbId ? await source.searchByTvdbId(params.tvdbId, params.season, params.episode) : await source.listReleases() - sourceSpan.setAttribute('release.count', items.length) + setSpanAttribute(sourceSpan, 'release.count', items.length) return items }) } @@ -101,7 +102,7 @@ export class PeerController { })) const flat = results.flat() - span.setAttribute('release.count', flat.length) + setSpanAttribute(span, 'release.count', flat.length) return flat }) } @@ -126,11 +127,11 @@ export class PeerController { }, async (span) => { const source = this.findSource(id) if (!source) { - span.setAttribute('source.found', false) + setSpanAttribute(span, 'source.found', false) return null } - span.setAttributes({ + setSpanAttributes(span, { 'source.found': true, 'source.name': source.name, 'source.type': source.type, @@ -138,21 +139,21 @@ export class PeerController { const filePath = await source.getFilePath(id) if (!filePath) { - span.setAttribute('file.path_found', false) + setSpanAttribute(span, 'file.path_found', false) return null } - span.setAttribute('file.path_found', true) + setSpanAttribute(span, 'file.path_found', true) const file = Bun.file(filePath) if (!await file.exists()) { - span.setAttribute('file.exists', false) + setSpanAttribute(span, 'file.exists', false) logger.warn({ filePath, id }, 'File not found on disk') return null } const totalSize = file.size const filename = filePath.split('/').pop() ?? 'unknown' - span.setAttributes({ 'file.exists': true, 'file.size': totalSize }) + setSpanAttributes(span, { 'file.exists': true, 'file.size': totalSize }) const range = parseRangeHeader(rangeHeader) if (!range) { @@ -165,7 +166,7 @@ export class PeerController { // Suffix range: `bytes=-N` → last N bytes. const suffix = range.end ?? 0 if (suffix <= 0) { - span.setAttribute('range.satisfiable', false) + setSpanAttribute(span, 'range.satisfiable', false) return { type: 'unsatisfiable', totalSize } } start = Math.max(totalSize - suffix, 0) @@ -177,11 +178,11 @@ export class PeerController { } if (start > end || start >= totalSize) { - span.setAttribute('range.satisfiable', false) + setSpanAttribute(span, 'range.satisfiable', false) return { type: 'unsatisfiable', totalSize } } - span.setAttributes({ 'range.satisfiable': true, 'range.start': start, 'range.end': end }) + setSpanAttributes(span, { 'range.satisfiable': true, 'range.start': start, 'range.end': end }) // Bun.file().slice is half-open [start, end), so +1 to include `end`. return { type: 'partial', body: file.slice(start, end + 1), size: end - start + 1, totalSize, start, end, filename } }) diff --git a/apps/backend/src/modules/torznab/torznab.controller.ts b/apps/backend/src/modules/torznab/torznab.controller.ts index 29c20ec..ff14930 100644 --- a/apps/backend/src/modules/torznab/torznab.controller.ts +++ b/apps/backend/src/modules/torznab/torznab.controller.ts @@ -1,6 +1,7 @@ import type { AppConfig } from '../../lib/config' import type { Release } from '../../lib/release' import type { PeerConnector } from '../../lib/servers/peer' +import { setSpanAttribute } from '../../lib/span-attributes' import { withSpan } from '../../lib/tracing' import { logger } from '../../logger' @@ -64,7 +65,7 @@ export class TorznabController { 'peer.count': this.peers.length, }, async (span) => { if (this.peers.length === 0) { - span.setAttribute('release.count', 0) + setSpanAttribute(span, 'release.count', 0) return [] } @@ -77,7 +78,7 @@ export class TorznabController { 'peer.id': peer.id, }, async (peerSpan) => { const releases = await search(peer) - peerSpan.setAttribute('release.count', releases.length) + setSpanAttribute(peerSpan, 'release.count', releases.length) return releases.map(release => releaseToTorznab(release, peer.id, peer.name, this.jackConfig.baseUrl, this.jackConfig.apiKey)) }) } @@ -89,7 +90,7 @@ export class TorznabController { ) const items = results.flat() - span.setAttribute('release.count', items.length) + setSpanAttribute(span, 'release.count', items.length) return items }) } diff --git a/eslint.config.mjs b/eslint.config.mjs index e0cebad..adb580b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,10 +1,27 @@ import antfu from '@antfu/eslint-config' -export default antfu({ - typescript: true, - rules: { - 'ts/no-redeclare': 'off', - 'antfu/no-top-level-await': 'off', - // 'jsonc/comma-dangle': ['warn', 'always-multiline'], +export default antfu( + { + typescript: true, + rules: { + 'ts/no-redeclare': 'off', + 'antfu/no-top-level-await': 'off', + // 'jsonc/comma-dangle': ['warn', 'always-multiline'], + }, }, -}) + { + // Force all span attributes through the redacting/serializing funnel in + // lib/span-attributes.ts. The helper itself is the only sanctioned caller. + files: ['apps/backend/**/*.ts'], + ignores: ['apps/backend/src/lib/span-attributes.ts'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'CallExpression[callee.property.name=/^setAttributes?$/]', + message: 'Do not call span.setAttribute(s) directly. Use setSpanAttribute(s) from lib/span-attributes.ts so values are redacted, serialized, and truncated.', + }, + ], + }, + }, +) From 93e2948f4759a0f362b4eed26814aad14ea7025e Mon Sep 17 00:00:00 2001 From: Roz Date: Thu, 11 Jun 2026 00:36:49 +0200 Subject: [PATCH 06/22] test: set required config version in app fixtures --- apps/backend/src/__tests__/downloads-api.test.ts | 3 ++- apps/backend/src/__tests__/handshake.test.ts | 3 ++- apps/backend/src/__tests__/integration.test.ts | 3 ++- apps/backend/src/__tests__/peer-handshake.test.ts | 3 ++- apps/backend/src/__tests__/qbittorrent-api.test.ts | 4 +++- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/__tests__/downloads-api.test.ts b/apps/backend/src/__tests__/downloads-api.test.ts index 329617b..9c51567 100644 --- a/apps/backend/src/__tests__/downloads-api.test.ts +++ b/apps/backend/src/__tests__/downloads-api.test.ts @@ -6,7 +6,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { getApp } from '../app' import { openDatabase } from '../database/connection' -import { AppConfig } from '../lib/config' +import { AppConfig, MIGRATIONS } from '../lib/config' import { DownloadsRepository } from '../modules/downloads/downloads.repository' const envs: Envs = { @@ -21,6 +21,7 @@ const envs: Envs = { } const config = AppConfig.parse({ + version: MIGRATIONS.length, jack: { baseUrl: 'http://localhost:3000', apiKey: 'test-api-key' }, downloads: { completedPath: '/tmp/completed' }, servers: [], diff --git a/apps/backend/src/__tests__/handshake.test.ts b/apps/backend/src/__tests__/handshake.test.ts index c872b79..e5fd2f5 100644 --- a/apps/backend/src/__tests__/handshake.test.ts +++ b/apps/backend/src/__tests__/handshake.test.ts @@ -1,12 +1,13 @@ import { describe, expect, test } from 'bun:test' import { getApp } from '../app' -import { AppConfig } from '../lib/config' +import { AppConfig, MIGRATIONS } from '../lib/config' import { PROTOCOL_VERSION } from '../lib/version' const envs = { ENVIRONMENT: 'test', ENABLE_LOGS: false, LOG_LEVEL: 'fatal' } as any function buildApp() { const config = AppConfig.parse({ + version: MIGRATIONS.length, jack: { baseUrl: 'http://jack:5225', apiKey: 'test-api-key' }, servers: [], peers: [], diff --git a/apps/backend/src/__tests__/integration.test.ts b/apps/backend/src/__tests__/integration.test.ts index 8567ac8..51b8442 100644 --- a/apps/backend/src/__tests__/integration.test.ts +++ b/apps/backend/src/__tests__/integration.test.ts @@ -8,7 +8,7 @@ import { setupServer } from 'msw/node' import { getApp } from '../app' import { runMigrations } from '../database/connection' import * as schema from '../database/schema' -import { AppConfig } from '../lib/config' +import { AppConfig, MIGRATIONS } from '../lib/config' import { RadarrServerConnector } from '../lib/servers/arr/radarr' import { SonarrServerConnector } from '../lib/servers/arr/sonarr' import { PeerConnector } from '../lib/servers/peer' @@ -109,6 +109,7 @@ afterEach(() => { afterAll(() => server.close()) const config = AppConfig.parse({ + version: MIGRATIONS.length, jack: { baseUrl: 'http://localhost:3000', apiKey: 'test-api-key' }, downloads: { completedPath: '/tmp/jack-test-completed' }, servers: [], diff --git a/apps/backend/src/__tests__/peer-handshake.test.ts b/apps/backend/src/__tests__/peer-handshake.test.ts index f669e18..4719805 100644 --- a/apps/backend/src/__tests__/peer-handshake.test.ts +++ b/apps/backend/src/__tests__/peer-handshake.test.ts @@ -2,7 +2,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { getApp } from '../app' -import { AppConfig } from '../lib/config' +import { AppConfig, MIGRATIONS } from '../lib/config' import { PeerConnector } from '../lib/servers/peer' import { ServersController } from '../modules/servers/servers.controllers' @@ -125,6 +125,7 @@ describe('ServersController surfaces peer version', () => { await peer.initialization const config = AppConfig.parse({ + version: MIGRATIONS.length, jack: { baseUrl: 'http://jack:5225', apiKey: 'test-api-key' }, servers: [], peers: [], diff --git a/apps/backend/src/__tests__/qbittorrent-api.test.ts b/apps/backend/src/__tests__/qbittorrent-api.test.ts index 5b9b312..166e23a 100644 --- a/apps/backend/src/__tests__/qbittorrent-api.test.ts +++ b/apps/backend/src/__tests__/qbittorrent-api.test.ts @@ -4,7 +4,7 @@ import { drizzle } from 'drizzle-orm/bun-sqlite' import { getApp } from '../app' import { runMigrations } from '../database/connection' import * as schema from '../database/schema' -import { AppConfig } from '../lib/config' +import { AppConfig, MIGRATIONS } from '../lib/config' import { DownloadsRepository } from '../modules/downloads/downloads.repository' import { deriveHash, qbCategoryForServer } from '../modules/qbittorrent/qbittorrent.mapper' import { createTorrentStub } from '../modules/torznab/torrent' @@ -19,6 +19,7 @@ function buildApp() { runMigrations(db) const repository = new DownloadsRepository(db) const config = AppConfig.parse({ + version: MIGRATIONS.length, jack: { baseUrl: 'http://jack:5225', apiKey: 'test-api-key' }, downloads: { completedPath: '/tmp/completed' }, servers: [], @@ -34,6 +35,7 @@ function buildAppWithService(startResult: 'started' | 'duplicate' | 'failed' = ' runMigrations(db) const repository = new DownloadsRepository(db) const config = AppConfig.parse({ + version: MIGRATIONS.length, jack: { baseUrl: 'http://jack:5225', apiKey: 'test-api-key' }, downloads: { completedPath: '/tmp/completed' }, servers: [], From 80c598aeb0058241c8bd61a6e7cbf77caf7e6e8d Mon Sep 17 00:00:00 2001 From: Roz Date: Thu, 11 Jun 2026 07:47:13 +0200 Subject: [PATCH 07/22] style: use single quotes for release tag glob --- .github/workflows/create-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 8c84811..58282a4 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -4,7 +4,7 @@ name: Create Release on: push: tags: - - "v*" + - 'v*' permissions: contents: write From 6fcd297af2476978441c25fdb24450c2b356c133 Mon Sep 17 00:00:00 2001 From: Roz Date: Sun, 14 Jun 2026 21:35:44 +0200 Subject: [PATCH 08/22] feat: add management API on a separate port with read endpoints 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. --- .../src/__tests__/config-management.test.ts | 72 +++++++++++++++++++ .../src/__tests__/downloads-api.test.ts | 1 + .../backend/src/__tests__/integration.test.ts | 8 ++- apps/backend/src/index.ts | 38 +++++++--- apps/backend/src/lib/envs.ts | 8 +++ apps/backend/src/management-app.ts | 27 +++++++ .../src/middleware/require-management-key.ts | 31 ++++++++ .../src/modules/config/config.controller.ts | 43 +++++++++++ .../src/modules/config/config.router.ts | 12 ++++ 9 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 apps/backend/src/__tests__/config-management.test.ts create mode 100644 apps/backend/src/management-app.ts create mode 100644 apps/backend/src/middleware/require-management-key.ts create mode 100644 apps/backend/src/modules/config/config.controller.ts create mode 100644 apps/backend/src/modules/config/config.router.ts diff --git a/apps/backend/src/__tests__/config-management.test.ts b/apps/backend/src/__tests__/config-management.test.ts new file mode 100644 index 0000000..4d7e038 --- /dev/null +++ b/apps/backend/src/__tests__/config-management.test.ts @@ -0,0 +1,72 @@ +import type { Envs } from '../lib/envs' +import { describe, expect, test } from 'bun:test' +import { getApp } from '../app' +import { AppConfig, MIGRATIONS } from '../lib/config' +import { PeerConnector } from '../lib/servers/peer' +import { getManagementApp } from '../management-app' + +const config = AppConfig.parse({ + version: MIGRATIONS.length, + jack: { baseUrl: 'http://localhost:3000', apiKey: 'test-api-key' }, + downloads: { completedPath: '/tmp/jack-test-completed' }, + servers: [], + peers: [], +}) + +function makeEnvs(managementKey?: string): Envs { + return { + APP_CONFIG_PATH: '/data/config.json', + ENABLE_LOGS: false, + ENVIRONMENT: 'test' as any, + HTTP_TIMEOUT_MS: 3000, + LOG_LEVEL: 'fatal', + OTEL_SERVICE_NAME: 'jack-server', + PORT: 3000, + MANAGEMENT_PORT: 5226, + NODE_ENV: 'test', + MANAGEMENT_KEY: managementKey, + } +} + +function markInitialized(connector: T): T { + const c = connector as any + c._isInitialized = true + c._initState = 'initialized' + c._initialization.resolve() + return connector +} + +function makePeer() { + return markInitialized(new PeerConnector({ url: 'http://peer.test:3000', apiKey: 'peer-api-key', name: 'Friend Jack' })) +} + +function mgmtApp(managementKey = 'mgmt-secret', peers = [makePeer()]) { + return getManagementApp({ environment: 'test', managementKey, connectors: { servers: [], peers } }) +} + +describe('Management API auth', () => { + test('GET /config/peers with valid key returns 200 + peers', async () => { + const res = await mgmtApp().request('/config/peers', { headers: { 'X-Management-Key': 'mgmt-secret' } }) + expect(res.status).toBe(200) + const body = await res.json() as { peers: Array<{ name: string }> } + expect(body.peers[0]?.name).toBe('Friend Jack') + }) + + test('GET /config/peers without key returns 401', async () => { + const res = await mgmtApp().request('/config/peers') + expect(res.status).toBe(401) + }) + + test('GET /config/peers with wrong key returns 401', async () => { + const res = await mgmtApp().request('/config/peers', { headers: { 'X-Management-Key': 'wrong' } }) + expect(res.status).toBe(401) + }) + + test('the public app never exposes /config', async () => { + const app = getApp(makeEnvs(undefined), config, { servers: [], peers: [makePeer()] } as any) + // Carry a valid peer API key so we get past requireApiKey and reach routing: + // a true 404 proves the route is unregistered, not merely auth-blocked. + const res = await app.request('/config', { headers: { 'x-api-key': 'test-api-key' } }) + expect(res.status).toBe(404) + }) +}) diff --git a/apps/backend/src/__tests__/downloads-api.test.ts b/apps/backend/src/__tests__/downloads-api.test.ts index 9c51567..7276faf 100644 --- a/apps/backend/src/__tests__/downloads-api.test.ts +++ b/apps/backend/src/__tests__/downloads-api.test.ts @@ -17,6 +17,7 @@ const envs: Envs = { LOG_LEVEL: 'fatal', OTEL_SERVICE_NAME: 'jack-server', PORT: 3000, + MANAGEMENT_PORT: 5226, NODE_ENV: 'test', } diff --git a/apps/backend/src/__tests__/integration.test.ts b/apps/backend/src/__tests__/integration.test.ts index 51b8442..2651935 100644 --- a/apps/backend/src/__tests__/integration.test.ts +++ b/apps/backend/src/__tests__/integration.test.ts @@ -124,13 +124,19 @@ const envs: Envs = { LOG_LEVEL: 'fatal', OTEL_SERVICE_NAME: 'jack-server', PORT: 3000, + MANAGEMENT_PORT: 5226, NODE_ENV: 'test', } const AUTOREGISTER = { enable: true, priority: 1 } function markInitialized(connector: T): T { - ;(connector as any)._isInitialized = true + const c = connector as any + c._isInitialized = true + c._initState = 'initialized' + // The init guard awaits the `initialization` promise; resolve it so guarded + // calls don't hang waiting on an init that the test skips. + c._initialization.resolve() return connector } diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 4d2db14..68f7671 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -5,8 +5,9 @@ import { shutdownTelemetry } from './instrumentation' import { getAppConfig } from './lib/config' import { getAppEnvs } from './lib/envs' import { FetchError } from './lib/errors/FetchError' -import { initializeConnectors } from './lib/servers' +import { ConnectorManager } from './lib/servers' import { logger } from './logger' +import { getManagementApp } from './management-app' import { DownloadsRepository } from './modules/downloads/downloads.repository' import { DownloadsService } from './modules/downloads/downloads.service' import { qbCategoryForServer } from './modules/qbittorrent/qbittorrent.mapper' @@ -26,17 +27,17 @@ const envs = getAppEnvs() logger.debug('Loading app config') const config = await getAppConfig(envs) -const connectors = await initializeConnectors(config) -const destinations = connectors.servers.filter(s => s.canDestination) +const connectorManager = new ConnectorManager(config.servers, config.peers) +await connectorManager.initAll() const database = await openDatabase({ appConfigPath: envs.APP_CONFIG_PATH }) const downloadsRepository = new DownloadsRepository(database.db) const downloadsService = config.downloads - ? new DownloadsService(config.downloads, connectors.peers, downloadsRepository) + ? new DownloadsService(config.downloads, connectorManager, downloadsRepository) : undefined -const app = getApp(envs, config, connectors, { downloadsRepository, downloadsService }) +const app = getApp(envs, config, connectorManager, { downloadsRepository, downloadsService }) const server = Bun.serve({ fetch: app.fetch, }) @@ -45,11 +46,28 @@ logger.info({ port: server.port, configPath: envs.APP_CONFIG_PATH, databasePath: database.path, - sources: connectors.servers.filter(c => c.isInitialized && c.canSource).length, - peers: connectors.peers.filter(c => c.isInitialized).length, - destinations: destinations.filter(c => c.isInitialized).length, + sources: connectorManager.sources.length, + peers: connectorManager.peers.length, + destinations: connectorManager.destinations.length, }, 'Server listening') +// Module-scope so the SIGINT/SIGTERM handlers below can stop it too. +let managementServer: ReturnType | undefined +if (envs.MANAGEMENT_KEY) { + if (envs.MANAGEMENT_PORT === server.port) { + logger.fatal({ port: envs.MANAGEMENT_PORT }, 'MANAGEMENT_PORT collides with the public port; not starting the management API') + } + else { + const managementApp = getManagementApp({ + environment: envs.ENVIRONMENT, + managementKey: envs.MANAGEMENT_KEY, + connectors: connectorManager, + }) + managementServer = Bun.serve({ port: envs.MANAGEMENT_PORT, fetch: managementApp.fetch }) + logger.info({ port: managementServer.port }, 'Management API listening') + } +} + // Auto-register as a Torznab indexer + qBittorrent download client in each // destination that opts in via its `autoregister` config. We register even when // there are no peers / an empty catalog (forceSave on the *arr side), so the @@ -63,7 +81,7 @@ if (config.jack) { logger.warn('No "downloads" config set; skipping download client auto-registration. Grabs will fail until a qBittorrent client is configured.') } - const registrable = destinations.filter(d => d.isInitialized && d.autoRegister.enable) + const registrable = connectorManager.destinations.filter(d => d.isInitialized && d.autoRegister.enable) for (const dest of registrable) { // Register the download client first so we can bind the indexer to it: // grabs from the Jack indexer must go to the Jack qBittorrent client, not @@ -121,6 +139,7 @@ process.on('SIGINT', async () => { logger.info('SIGINT received, exiting') database.close() server.stop() + managementServer?.stop() await shutdownTelemetry() process.exit(0) }) @@ -129,6 +148,7 @@ process.on('SIGTERM', async () => { logger.info('SIGTERM received, exiting') database.close() server.stop() + managementServer?.stop() await shutdownTelemetry() process.exit(0) }) diff --git a/apps/backend/src/lib/envs.ts b/apps/backend/src/lib/envs.ts index a84c9c5..e6fcfde 100644 --- a/apps/backend/src/lib/envs.ts +++ b/apps/backend/src/lib/envs.ts @@ -17,6 +17,14 @@ export const Envs = z.object({ OTEL_SERVICE_NAME: z.string().default('jack-backend'), NODE_ENV: z.string().optional(), ENABLE_LOGS: z.stringbool().optional().default(true), + // Management API credential. When set, the management surface starts on its OWN + // port (MANAGEMENT_PORT) and every request must carry `X-Management-Key: `. + // When unset, the management listener is not started at all. + MANAGEMENT_KEY: z.string().min(1).optional(), + // Port for the management API listener (separate from the public PORT so the + // peer-facing port never exposes management at all). Only used when MANAGEMENT_KEY + // is set. + MANAGEMENT_PORT: z.coerce.number().int().default(5226), }).transform(vars => ({ ...vars, ENABLE_LOGS: vars.NODE_ENV !== 'test' && vars.ENABLE_LOGS, diff --git a/apps/backend/src/management-app.ts b/apps/backend/src/management-app.ts new file mode 100644 index 0000000..8431240 --- /dev/null +++ b/apps/backend/src/management-app.ts @@ -0,0 +1,27 @@ +import type { ConnectorManager } from './lib/servers' +import { Hono } from 'hono' +import { secureHeaders } from 'hono/secure-headers' +import { handleError } from './middleware/handle-error' +import { requireManagementKey } from './middleware/require-management-key' +import { ConfigController } from './modules/config/config.controller' +import { getConfigRouter } from './modules/config/config.router' + +export function getManagementApp(params: { + environment: string + managementKey: string + // The live manager (its `servers`/`peers` getters are read per request). + connectors: { servers: ConnectorManager['servers'], peers: ConnectorManager['peers'] } +}) { + const app = new Hono() + + app.use('*', secureHeaders()) + // The entire surface is key-guarded; no route is reachable without it. + app.use('*', requireManagementKey(params.managementKey)) + + const configController = new ConfigController(params.connectors) + app.route('/config', getConfigRouter(configController)) + + app.onError(handleError(params.environment)) + + return app +} diff --git a/apps/backend/src/middleware/require-management-key.ts b/apps/backend/src/middleware/require-management-key.ts new file mode 100644 index 0000000..606708a --- /dev/null +++ b/apps/backend/src/middleware/require-management-key.ts @@ -0,0 +1,31 @@ +import { createMiddleware } from 'hono/factory' +import { UnauthorizedError } from '../lib/errors/UnauthorizedError' + +// Hash both sides to a fixed 32-byte digest so the compare is constant-time and +// length-independent (timingSafeEqual throws on unequal lengths otherwise). +function digest(value: string): Uint8Array { + return new Bun.CryptoHasher('sha256').update(value).digest() as Uint8Array +} + +function constantTimeEqual(a: string, b: string): boolean { + const da = digest(a) + const db = digest(b) + let diff = 0 + for (let i = 0; i < da.length; i++) + diff |= da[i]! ^ db[i]! + return diff === 0 +} + +export function requireManagementKey(managementKey: string) { + return createMiddleware((ctx, next) => { + const provided = ctx.req.header('x-management-key') + + if (!provided) + throw new UnauthorizedError('missing management key') + + if (constantTimeEqual(provided, managementKey)) + return next() + + throw new UnauthorizedError('invalid management key') + }) +} diff --git a/apps/backend/src/modules/config/config.controller.ts b/apps/backend/src/modules/config/config.controller.ts new file mode 100644 index 0000000..3995841 --- /dev/null +++ b/apps/backend/src/modules/config/config.controller.ts @@ -0,0 +1,43 @@ +import type { ArrServerConnector } from '../../lib/servers/arr/base' +import type { ServerConnector } from '../../lib/servers/base' +import type { PeerConnector } from '../../lib/servers/peer' + +function stringifyConnector(c: ServerConnector) { + return { + id: c.id, + name: c.name, + url: c.url, + type: c.type, + initialized: c.isInitialized, + initializationError: c.initializationError, + } +} + +function stringifyServer(c: ArrServerConnector) { + return { ...stringifyConnector(c), source: c.canSource, destination: c.canDestination } +} + +function stringifyPeer(c: PeerConnector) { + return { ...stringifyConnector(c), version: c.peerVersion } +} + +export class ConfigController { + constructor( + private readonly connectors: { servers: ArrServerConnector[], peers: PeerConnector[] }, + ) {} + + listConfig() { + return { + servers: this.connectors.servers.map(stringifyServer), + peers: this.connectors.peers.map(stringifyPeer), + } + } + + listPeers() { + return { peers: this.connectors.peers.map(stringifyPeer) } + } + + listServers() { + return { servers: this.connectors.servers.map(stringifyServer) } + } +} diff --git a/apps/backend/src/modules/config/config.router.ts b/apps/backend/src/modules/config/config.router.ts new file mode 100644 index 0000000..f17271a --- /dev/null +++ b/apps/backend/src/modules/config/config.router.ts @@ -0,0 +1,12 @@ +import type { ConfigController } from './config.controller' +import { Hono } from 'hono' + +export function getConfigRouter(controller: ConfigController) { + const app = new Hono() + + app.get('/', c => c.json(controller.listConfig())) + app.get('/peers', c => c.json(controller.listPeers())) + app.get('/servers', c => c.json(controller.listServers())) + + return app +} From 9954a93adcb9e11e629d435512907b594443377e Mon Sep 17 00:00:00 2001 From: Roz Date: Sun, 14 Jun 2026 21:58:36 +0200 Subject: [PATCH 09/22] feat: add live addPeer via management API 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. --- .../src/__tests__/config-management.test.ts | 85 ++++++++++++++++++- apps/backend/src/index.ts | 8 ++ apps/backend/src/lib/atomic-write.ts | 12 +++ apps/backend/src/lib/config.ts | 55 +++++------- .../backend/src/lib/errors/BadRequestError.ts | 7 ++ apps/backend/src/lib/errors/ConflictError.ts | 7 ++ apps/backend/src/management-app.ts | 4 +- apps/backend/src/middleware/handle-error.ts | 4 + .../src/modules/config/config.controller.ts | 18 ++++ .../src/modules/config/config.router.ts | 5 ++ .../src/modules/config/config.service.ts | 72 ++++++++++++++++ 11 files changed, 241 insertions(+), 36 deletions(-) create mode 100644 apps/backend/src/lib/atomic-write.ts create mode 100644 apps/backend/src/lib/errors/BadRequestError.ts create mode 100644 apps/backend/src/lib/errors/ConflictError.ts create mode 100644 apps/backend/src/modules/config/config.service.ts diff --git a/apps/backend/src/__tests__/config-management.test.ts b/apps/backend/src/__tests__/config-management.test.ts index 4d7e038..c868fc4 100644 --- a/apps/backend/src/__tests__/config-management.test.ts +++ b/apps/backend/src/__tests__/config-management.test.ts @@ -1,9 +1,18 @@ import type { Envs } from '../lib/envs' -import { describe, expect, test } from 'bun:test' +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { jsonc } from 'jsonc' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' import { getApp } from '../app' import { AppConfig, MIGRATIONS } from '../lib/config' +import { ConnectorManager } from '../lib/servers' import { PeerConnector } from '../lib/servers/peer' +import { PROTOCOL_VERSION } from '../lib/version' import { getManagementApp } from '../management-app' +import { ConfigService } from '../modules/config/config.service' const config = AppConfig.parse({ version: MIGRATIONS.length, @@ -70,3 +79,77 @@ describe('Management API auth', () => { expect(res.status).toBe(404) }) }) + +const mswServer = setupServer( + http.get('http://bob.test:3000/handshake', () => HttpResponse.json({ name: 'jack', version: PROTOCOL_VERSION })), + http.get('http://carol.test:3000/handshake', () => HttpResponse.json({ name: 'jack', version: PROTOCOL_VERSION })), +) +beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' })) +afterAll(() => mswServer.close()) + +const tempFiles: string[] = [] +afterEach(async () => { + for (const f of tempFiles.splice(0)) + await rm(f, { force: true }) +}) + +// `app` is the MANAGEMENT app (where /config lives); `mainApp` is the public peer +// app (where /torznab etc. live). Both share the same in-process connectorManager + +// configService, so a mutation on `app` is visible to `mainApp` (see Phase 7). +async function makeMutableApp(managementKey = 'mgmt-secret') { + const path = join(tmpdir(), `jack-config-${Math.random().toString(36).slice(2)}.jsonc`) + tempFiles.push(path) + await Bun.write(path, jsonc.stringify({ version: 1, peers: [], servers: [] }, { space: 2 })) + const connectorManager = new ConnectorManager([], []) + const configService = await ConfigService.fromFile({ path, connectorManager }) + const app = getManagementApp({ environment: 'test', managementKey, connectors: connectorManager, configService }) + const mainApp = getApp(makeEnvs(managementKey), config, connectorManager) + return { app, mainApp, path, connectorManager } +} + +const KEY = { 'X-Management-Key': 'mgmt-secret' } as const + +describe('Management API addPeer', () => { + test('adds a peer live and preserves the secret ref in the file', async () => { + process.env.BOB_KEY = 'bob-secret' + const { app, path, connectorManager } = await makeMutableApp() + + const res = await app.request('/config/peers', { + method: 'POST', + headers: { ...KEY, 'Content-Type': 'application/json' }, + // `bogus` is an unknown field — it must be stripped before persisting. + body: JSON.stringify({ name: 'Bob', url: 'http://bob.test:3000', apiKey: { env: 'BOB_KEY' }, bogus: 'x' }), + }) + expect(res.status).toBe(201) + + const onDisk = jsonc.parse(await Bun.file(path).text()) as { peers: Array<{ apiKey: unknown, bogus?: unknown }> } + expect(onDisk.peers[0]?.apiKey).toEqual({ env: 'BOB_KEY' }) + expect(onDisk.peers[0]?.bogus).toBeUndefined() + expect(connectorManager.peers.some(p => p.url === 'http://bob.test:3000')).toBe(true) + }) + + test('rejects a duplicate url with 409', async () => { + process.env.BOB_KEY = 'bob-secret' + const { app } = await makeMutableApp() + const body = JSON.stringify({ name: 'Bob', url: 'http://bob.test:3000', apiKey: 'k' }) + await app.request('/config/peers', { method: 'POST', headers: { ...KEY, 'Content-Type': 'application/json' }, body }) + const res = await app.request('/config/peers', { method: 'POST', headers: { ...KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Bob2', url: 'http://bob.test:3000', apiKey: 'k' }) }) + expect(res.status).toBe(409) + }) + + test('rejects an invalid body with 400', async () => { + const { app } = await makeMutableApp() + const res = await app.request('/config/peers', { method: 'POST', headers: { ...KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'NoUrl' }) }) + expect(res.status).toBe(400) + }) + + test('serializes concurrent adds without losing an update', async () => { + const { app, path } = await makeMutableApp() + await Promise.all([ + app.request('/config/peers', { method: 'POST', headers: { ...KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Bob', url: 'http://bob.test:3000', apiKey: 'k' }) }), + app.request('/config/peers', { method: 'POST', headers: { ...KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Carol', url: 'http://carol.test:3000', apiKey: 'k' }) }), + ]) + const onDisk = jsonc.parse(await Bun.file(path).text()) as { peers: unknown[] } + expect(onDisk.peers).toHaveLength(2) + }) +}) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 68f7671..39c47a2 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -8,6 +8,7 @@ import { FetchError } from './lib/errors/FetchError' import { ConnectorManager } from './lib/servers' import { logger } from './logger' import { getManagementApp } from './management-app' +import { ConfigService } from './modules/config/config.service' import { DownloadsRepository } from './modules/downloads/downloads.repository' import { DownloadsService } from './modules/downloads/downloads.service' import { qbCategoryForServer } from './modules/qbittorrent/qbittorrent.mapper' @@ -30,6 +31,12 @@ const config = await getAppConfig(envs) const connectorManager = new ConnectorManager(config.servers, config.peers) await connectorManager.initAll() +// NOTE: Phase 6 replaces this independent read with the shared raw object returned +// by getAppConfig (see Phase 6) so the service can't diverge from the loaded config. +const configService = envs.MANAGEMENT_KEY + ? await ConfigService.fromFile({ path: envs.APP_CONFIG_PATH, connectorManager }) + : undefined + const database = await openDatabase({ appConfigPath: envs.APP_CONFIG_PATH }) const downloadsRepository = new DownloadsRepository(database.db) @@ -62,6 +69,7 @@ if (envs.MANAGEMENT_KEY) { environment: envs.ENVIRONMENT, managementKey: envs.MANAGEMENT_KEY, connectors: connectorManager, + configService, }) managementServer = Bun.serve({ port: envs.MANAGEMENT_PORT, fetch: managementApp.fetch }) logger.info({ port: managementServer.port }, 'Management API listening') diff --git a/apps/backend/src/lib/atomic-write.ts b/apps/backend/src/lib/atomic-write.ts new file mode 100644 index 0000000..0bb3037 --- /dev/null +++ b/apps/backend/src/lib/atomic-write.ts @@ -0,0 +1,12 @@ +import { rename } from 'node:fs/promises' + +/** + * Write `contents` to `path` atomically: write a sibling `.tmp` file then rename it + * over the target. rename(2) within a directory is atomic, so a reader never sees a + * half-written config and a crash mid-write leaves the original intact. + */ +export async function atomicWriteFile(path: string, contents: string): Promise { + const tmp = `${path}.tmp` + await Bun.write(tmp, contents) + await rename(tmp, path) +} diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index ad27d7b..2dc89f6 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -22,7 +22,7 @@ const TRAILING_LINE_ENDINGS = /[\r\n]+$/ * * @param value - schema used to validate the resolved string (defaults to a * non-empty string). It is applied both to literal strings and to values loaded - * from the environment or filesystem. + * from the environment or filesystem */ export function ConfigSecret(value: z.ZodType = z.string().min(1)) { return z @@ -74,22 +74,25 @@ export function ConfigSecret(value: z.ZodType = z.string().min(1 .pipe(value) } -// A jack-managed server is always a Radarr or Sonarr instance: it can act as a -// source (its library is shared with peers), a destination (jack registers -// itself there and triggers imports), or both. +// Raw (ref-preserving) secret: the union BEFORE ConfigSecret resolves it. Used only +// for persistence so the versioned file keeps {env}/{file} refs. Declared up here so +// both RawPeerConfig (below) and RawServerConfig (Phase 5) can reference it. +export const RawConfigSecret = z.union([ + z.string(), + z.object({ env: z.string().min(1) }), + z.object({ file: z.string().min(1) }), +]) + export const ServerType = z.enum(['radarr', 'sonarr']) export type ServerType = z.infer -// The connector base also models peers (other jacks), which are sources only. export type ConnectorType = ServerType | 'jack' export const ConnectorHeadersConfig = z.record(z.string(), ConfigSecret()).default({}) export type ConnectorHeadersConfig = z.infer -// Auto-registration of jack as a Torznab indexer + qBittorrent download -// client inside the *arr. `priority` is the indexer/client priority used there. export const AutoRegisterConfig = z.object({ enable: z.boolean().default(true), priority: z.number().int().min(1).default(1), @@ -103,17 +106,13 @@ export const ServerConfig = z.object({ apiKey: ConfigSecret(z.hex().min(32).max(32)), headers: ConnectorHeadersConfig, type: ServerType, - // Expose this server's library to peers (read by /peer/search). source: z.boolean().default(true), - // Register jack into this server and trigger imports there (written to). destination: z.boolean().default(true), autoregister: AutoRegisterConfig.prefault({}), }) export type ServerConfig = z.infer -// A peer is another jack instance we fan out to over the /peer API. Sources -// only — the source/destination/autoregister flags don't apply. export const PeerConfig = z.object({ name: z.string(), url: z.url(), @@ -123,6 +122,17 @@ export const PeerConfig = z.object({ export type PeerConfig = z.infer +// Raw peer for persistence: declares exactly the fields we store, so unknown keys +// from a management-client body are stripped before they reach the file. +export const RawPeerConfig = z.object({ + name: z.string(), + url: z.url(), + apiKey: RawConfigSecret, + headers: z.record(z.string(), RawConfigSecret).optional(), +}) + +export type RawPeerConfig = z.infer + export const JackConfig = z.object({ baseUrl: z.url(), apiKey: ConfigSecret(), @@ -132,27 +142,10 @@ export type JackConfig = z.infer export const DownloadsConfig = z.object({ completedPath: z.string().min(1), - // Max peer file downloads running at once (an async semaphore guards the - // expensive download step). Defaults keep existing configs working. maxConcurrentDownloads: z.number().int().min(1).default(3), - // Bounded retries for transient failures, with exponential backoff + jitter. - // A peer (another jack) can go unreachable for ~15-30 min (restart, tunnel - // hiccup); since the .part is preserved and fully resumable, the schedule must - // span long enough to outlast such an outage rather than fail fast. - // - // The backoff (see lib/retry.ts) is full-jitter exponential: each retry waits - // up to `min(maxDelayMs, baseDelayMs * 2^(attempt-1))`. Starting at 1s and - // capped at 30min, the uncapped backoff reaches the cap at attempt 12 - // (2^11 = 2048 >= 1800). With 13 total attempts there are 12 retries whose - // max delays are 1s,2s,4s,...,512s,1024s(~17m),1800s(30m cap) — a worst-case - // total retry window of ~64min (≈32min on average with jitter). That keeps a - // ~17min outage well within reach while early retries stay snappy (≈1s) for - // ordinary network blips. maxDownloadAttempts: z.number().int().min(1).default(13), retryBaseDelayMs: z.number().int().min(0).default(1000), retryMaxDelayMs: z.number().int().min(0).default(1_800_000), - // Abort a peer download if no bytes arrive for this long (inactivity timeout). - // Resets on every received chunk; replaces the old whole-request deadline. idleTimeoutMs: z.number().int().min(1000).default(60_000), }) @@ -194,10 +187,6 @@ export function migrateConfig(rawConfigObject: unknown) { }, configObject) } -// Template written to disk to bootstrap a fresh install. API keys default to the -// `{ env: "..." }` form so secrets can be supplied via environment variables -// instead of being hardcoded in the file. Typed as the schema *input* so the -// env-reference shape is allowed here. const DEFAULT_APP_CONFIG: z.input = { version: MIGRATIONS.length, jack: { @@ -208,8 +197,6 @@ const DEFAULT_APP_CONFIG: z.input = { peers: [], } -// Fallback returned on first boot when the default's env references aren't set -// yet, so the app keeps starting instead of crashing on a fresh install. const EMPTY_APP_CONFIG: AppConfig = { version: MIGRATIONS.length, servers: [], diff --git a/apps/backend/src/lib/errors/BadRequestError.ts b/apps/backend/src/lib/errors/BadRequestError.ts new file mode 100644 index 0000000..bb5fba5 --- /dev/null +++ b/apps/backend/src/lib/errors/BadRequestError.ts @@ -0,0 +1,7 @@ +import { AppError } from './AppError' + +export class BadRequestError extends AppError { + constructor(message: string, cause?: unknown) { + super(message, 'BAD_REQUEST', { cause }) + } +} diff --git a/apps/backend/src/lib/errors/ConflictError.ts b/apps/backend/src/lib/errors/ConflictError.ts new file mode 100644 index 0000000..a4a6e7e --- /dev/null +++ b/apps/backend/src/lib/errors/ConflictError.ts @@ -0,0 +1,7 @@ +import { AppError } from './AppError' + +export class ConflictError extends AppError { + constructor(message: string, cause?: unknown) { + super(message, 'CONFLICT', { cause }) + } +} diff --git a/apps/backend/src/management-app.ts b/apps/backend/src/management-app.ts index 8431240..3c8a01c 100644 --- a/apps/backend/src/management-app.ts +++ b/apps/backend/src/management-app.ts @@ -1,4 +1,5 @@ import type { ConnectorManager } from './lib/servers' +import type { ConfigService } from './modules/config/config.service' import { Hono } from 'hono' import { secureHeaders } from 'hono/secure-headers' import { handleError } from './middleware/handle-error' @@ -11,6 +12,7 @@ export function getManagementApp(params: { managementKey: string // The live manager (its `servers`/`peers` getters are read per request). connectors: { servers: ConnectorManager['servers'], peers: ConnectorManager['peers'] } + configService?: ConfigService }) { const app = new Hono() @@ -18,7 +20,7 @@ export function getManagementApp(params: { // The entire surface is key-guarded; no route is reachable without it. app.use('*', requireManagementKey(params.managementKey)) - const configController = new ConfigController(params.connectors) + const configController = new ConfigController(params.connectors, params.configService) app.route('/config', getConfigRouter(configController)) app.onError(handleError(params.environment)) diff --git a/apps/backend/src/middleware/handle-error.ts b/apps/backend/src/middleware/handle-error.ts index 96ea64c..1d77c60 100644 --- a/apps/backend/src/middleware/handle-error.ts +++ b/apps/backend/src/middleware/handle-error.ts @@ -2,11 +2,15 @@ import type { Context } from 'hono' import type { ContentfulStatusCode } from 'hono/utils/http-status' import { accepts } from 'hono/accepts' import { xml } from '../helpers/xml' +import { BadRequestError } from '../lib/errors/BadRequestError' +import { ConflictError } from '../lib/errors/ConflictError' import { FetchError } from '../lib/errors/FetchError' import { UnauthorizedError } from '../lib/errors/UnauthorizedError' const STATUS_CODE_MAP = [ [UnauthorizedError, 401] as const, + [BadRequestError, 400] as const, + [ConflictError, 409] as const, [FetchError, 503] as const, ] diff --git a/apps/backend/src/modules/config/config.controller.ts b/apps/backend/src/modules/config/config.controller.ts index 3995841..e0f5fcd 100644 --- a/apps/backend/src/modules/config/config.controller.ts +++ b/apps/backend/src/modules/config/config.controller.ts @@ -1,6 +1,9 @@ import type { ArrServerConnector } from '../../lib/servers/arr/base' import type { ServerConnector } from '../../lib/servers/base' import type { PeerConnector } from '../../lib/servers/peer' +import type { ConfigService } from './config.service' +import { z } from 'zod' +import { BadRequestError } from '../../lib/errors/BadRequestError' function stringifyConnector(c: ServerConnector) { return { @@ -24,6 +27,7 @@ function stringifyPeer(c: PeerConnector) { export class ConfigController { constructor( private readonly connectors: { servers: ArrServerConnector[], peers: PeerConnector[] }, + private readonly configService?: ConfigService, ) {} listConfig() { @@ -40,4 +44,18 @@ export class ConfigController { listServers() { return { servers: this.connectors.servers.map(stringifyServer) } } + + async addPeer(input: unknown) { + if (!this.configService) + throw new Error('Config mutations require a configured ConfigService') + try { + await this.configService.addPeer(input) + } + catch (err) { + if (err instanceof z.ZodError) + throw new BadRequestError(z.prettifyError(err)) + throw err + } + return { ok: true } + } } diff --git a/apps/backend/src/modules/config/config.router.ts b/apps/backend/src/modules/config/config.router.ts index f17271a..3ee7615 100644 --- a/apps/backend/src/modules/config/config.router.ts +++ b/apps/backend/src/modules/config/config.router.ts @@ -8,5 +8,10 @@ export function getConfigRouter(controller: ConfigController) { app.get('/peers', c => c.json(controller.listPeers())) app.get('/servers', c => c.json(controller.listServers())) + app.post('/peers', async (c) => { + const body = await c.req.json().catch(() => null) + return c.json(await controller.addPeer(body), 201) + }) + return app } diff --git a/apps/backend/src/modules/config/config.service.ts b/apps/backend/src/modules/config/config.service.ts new file mode 100644 index 0000000..02da5b0 --- /dev/null +++ b/apps/backend/src/modules/config/config.service.ts @@ -0,0 +1,72 @@ +import type { z } from 'zod' +import type { AppConfig } from '../../lib/config' +import type { ConnectorManager } from '../../lib/servers' +import { jsonc } from 'jsonc' +import { atomicWriteFile } from '../../lib/atomic-write' +import { PeerConfig, RawPeerConfig } from '../../lib/config' +import { ConflictError } from '../../lib/errors/ConflictError' + +type RawConfig = z.input +type RawPeer = RawPeerConfig + +export class ConfigService { + #path: string + #raw: RawConfig + #connectorManager: ConnectorManager + // Serialized write queue: one async mutex every mutation chains onto, so file + // read-modify-write + map mutation never interleave between concurrent calls. + #queue: Promise = Promise.resolve() + + constructor(params: { path: string, raw: RawConfig, connectorManager: ConnectorManager }) { + this.#path = params.path + this.#raw = params.raw + this.#connectorManager = params.connectorManager + } + + /** Load the raw (refs-intact) config object from disk to seed the service. */ + static async fromFile(params: { path: string, connectorManager: ConnectorManager }): Promise { + const text = await Bun.file(params.path).text() + const raw = jsonc.parse(text) as RawConfig + return new ConfigService({ path: params.path, raw, connectorManager: params.connectorManager }) + } + + #enqueue(task: () => Promise): Promise { + const run = this.#queue.then(task, task) + // Swallow this task's result/error on the chain so a rejection doesn't poison + // the next enqueued task; the original promise still rejects to the caller. + this.#queue = run.then(() => {}, () => {}) + return run + } + + // Rollback-safe persist: write the CANDIDATE raw to disk first; the caller only + // assigns `this.#raw = next` AFTER this resolves, so a failed write never leaves + // in-memory state diverged from the file. + async #persist(next: RawConfig): Promise { + await atomicWriteFile(this.#path, jsonc.stringify(next, { space: 2 })) + } + + async addPeer(input: unknown): Promise { + // Validate + resolve secrets up front: a bad shape or unresolvable {env}/{file} + // ref throws (→ 400) BEFORE any file/map mutation. We persist the ORIGINAL + // input (refs intact), not the resolved value. + const resolved = PeerConfig.parse(input) + // RawPeerConfig.parse strips unknown keys but preserves {env}/{file} refs — this + // sanitized object (not the raw `input`) is what we persist. + const rawPeer = RawPeerConfig.parse(input) + + return this.#enqueue(async () => { + const peers = (this.#raw.peers ?? []) as RawPeer[] + + if (peers.some(p => p.url === resolved.url)) + throw new ConflictError(`A peer with url "${resolved.url}" already exists`) + if (peers.some(p => p.name === resolved.name)) + throw new ConflictError(`A peer named "${resolved.name}" already exists`) + + // Build the candidate, persist it, THEN commit in-memory + reconcile the map. + const next: RawConfig = { ...this.#raw, peers: [...peers, rawPeer] } + await this.#persist(next) + this.#raw = next + await this.#connectorManager.addPeerConnector(resolved) + }) + } +} From 9dd1f8a8a687fcab57ee3c91916c9a3ec61090bb Mon Sep 17 00:00:00 2001 From: Roz Date: Sun, 14 Jun 2026 22:13:18 +0200 Subject: [PATCH 10/22] feat: live removePeer + same-URL updatePeer via management API 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. --- .../src/__tests__/config-management.test.ts | 60 ++++++++ apps/backend/src/lib/errors/NotFoundError.ts | 7 + apps/backend/src/lib/servers/base.ts | 29 ++-- apps/backend/src/lib/servers/index.ts | 136 ++++++++++++++---- apps/backend/src/middleware/handle-error.ts | 2 + .../src/modules/config/config.controller.ts | 21 +++ .../src/modules/config/config.router.ts | 9 ++ .../src/modules/config/config.service.ts | 52 +++++++ 8 files changed, 282 insertions(+), 34 deletions(-) create mode 100644 apps/backend/src/lib/errors/NotFoundError.ts diff --git a/apps/backend/src/__tests__/config-management.test.ts b/apps/backend/src/__tests__/config-management.test.ts index c868fc4..1ba3db0 100644 --- a/apps/backend/src/__tests__/config-management.test.ts +++ b/apps/backend/src/__tests__/config-management.test.ts @@ -9,6 +9,7 @@ import { setupServer } from 'msw/node' import { getApp } from '../app' import { AppConfig, MIGRATIONS } from '../lib/config' import { ConnectorManager } from '../lib/servers' +import { generateId } from '../lib/servers/base' import { PeerConnector } from '../lib/servers/peer' import { PROTOCOL_VERSION } from '../lib/version' import { getManagementApp } from '../management-app' @@ -109,6 +110,22 @@ async function makeMutableApp(managementKey = 'mgmt-secret') { const KEY = { 'X-Management-Key': 'mgmt-secret' } as const +describe('ConnectorManager enabled filtering', () => { + test('peers getter excludes a disabled peer but the connector stays resident', () => { + const peer = makePeer() + const manager = new ConnectorManager([], []) + // Inject the peer into the live map, then disable it. + ;(manager as any)._peerMap.set(peer.id, peer) + expect(manager.peers).toHaveLength(1) + + manager.removeConnector(peer.id) + expect(manager.peers).toHaveLength(0) + // Still resident in the internal map (for in-flight drain). + expect((manager as any)._peerMap.get(peer.id)).toBe(peer) + expect(peer.enabled).toBe(false) + }) +}) + describe('Management API addPeer', () => { test('adds a peer live and preserves the secret ref in the file', async () => { process.env.BOB_KEY = 'bob-secret' @@ -153,3 +170,46 @@ describe('Management API addPeer', () => { expect(onDisk.peers).toHaveLength(2) }) }) + +describe('Management API remove/update peer', () => { + const BOB = { name: 'Bob', url: 'http://bob.test:3000', apiKey: 'k' } + const bobId = generateId(BOB.url) + + async function addBob(app: Awaited>['app']) { + return app.request('/config/peers', { method: 'POST', headers: { ...KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(BOB) }) + } + + test('removePeer drops it from file and fan-out', async () => { + const { app, path, connectorManager } = await makeMutableApp() + await addBob(app) + + const res = await app.request(`/config/peers/${bobId}`, { method: 'DELETE', headers: KEY }) + expect(res.status).toBe(200) + + const onDisk = jsonc.parse(await Bun.file(path).text()) as { peers: unknown[] } + expect(onDisk.peers).toHaveLength(0) + expect(connectorManager.peers).toHaveLength(0) + }) + + test('updatePeer renames in file and live connector (same id)', async () => { + const { app, path, connectorManager } = await makeMutableApp() + await addBob(app) + + const res = await app.request(`/config/peers/${bobId}`, { + method: 'PATCH', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...BOB, name: 'Bobby' }), + }) + expect(res.status).toBe(200) + + const onDisk = jsonc.parse(await Bun.file(path).text()) as { peers: Array<{ name: string }> } + expect(onDisk.peers[0]?.name).toBe('Bobby') + expect(connectorManager.peers.find(p => p.id === bobId)?.name).toBe('Bobby') + }) + + test('removePeer with unknown id returns 404', async () => { + const { app } = await makeMutableApp() + const res = await app.request('/config/peers/deadbeef', { method: 'DELETE', headers: KEY }) + expect(res.status).toBe(404) + }) +}) diff --git a/apps/backend/src/lib/errors/NotFoundError.ts b/apps/backend/src/lib/errors/NotFoundError.ts new file mode 100644 index 0000000..0c83557 --- /dev/null +++ b/apps/backend/src/lib/errors/NotFoundError.ts @@ -0,0 +1,7 @@ +import { AppError } from './AppError' + +export class NotFoundError extends AppError { + constructor(message: string, cause?: unknown) { + super(message, 'NOT_FOUND', { cause }) + } +} diff --git a/apps/backend/src/lib/servers/base.ts b/apps/backend/src/lib/servers/base.ts index b11aa6e..94eaff7 100644 --- a/apps/backend/src/lib/servers/base.ts +++ b/apps/backend/src/lib/servers/base.ts @@ -1,4 +1,4 @@ -import type { ConnectorHeadersConfig, ConnectorType } from '../config' +import type { ConnectorHeadersConfig, ConnectorType, ServerConfig } from '../config' import z from 'zod' import { logger } from '../../logger' import { getAppEnvs } from '../envs' @@ -9,7 +9,7 @@ import { withSpan } from '../tracing' const DEFAULT_FETCH_TIMEOUT_MS = getAppEnvs().HTTP_TIMEOUT_MS const MAX_ERROR_BODY_BYTES = 8 * 1024 -function generateId(url: string): string { +export function generateId(url: string): string { const hash = new Bun.CryptoHasher('sha256').update(url).digest('hex') return hash.slice(0, 8) } @@ -22,11 +22,12 @@ function truncateBody(body: string) { export abstract class ServerConnector { public readonly id: string + public readonly name: string public readonly type: ConnectorType public readonly url: string protected readonly apiKey: string protected readonly headers: ConnectorHeadersConfig - public readonly name: string + protected _enabled: boolean = true private readonly pingPath: string private readonly pingMethod: string @@ -34,11 +35,11 @@ export abstract class ServerConnector { private readonly authHeaderPrefix?: string protected _isInitialized: boolean = false - protected _initialization: ReturnType> | null = null + protected _initialization: ReturnType> = Promise.withResolvers() protected _initializationError: string | null = null protected _initState: 'idle' | 'pending' | 'initialized' | 'failed' = 'idle' - constructor(connectorConfig: { pingPath: string, pingMethod: string, authHeader: string, authHeaderPrefix?: string }, config: { type: ConnectorType, url: string, apiKey: string, name: string, headers?: ConnectorHeadersConfig }) { + constructor(connectorConfig: { pingPath: string, pingMethod: string, authHeader: string, authHeaderPrefix?: string }, config: ServerConfig) { this.pingPath = connectorConfig.pingPath this.pingMethod = connectorConfig.pingMethod this.authHeader = connectorConfig.authHeader @@ -57,7 +58,7 @@ export abstract class ServerConnector { } get initialization() { - return this._initialization?.promise + return this._initialization.promise } get initializationError() { @@ -71,6 +72,18 @@ export abstract class ServerConnector { } } + get enabled() { + return this._enabled + } + + public disable() { + this._enabled = false + } + + public enable() { + this._enabled = true + } + protected get authHeaderValue(): string { return `${this.authHeaderPrefix}${this.apiKey}` } @@ -201,12 +214,12 @@ export abstract class ServerConnector { .then(() => { this._isInitialized = true this._initState = 'initialized' - this._initialization?.resolve() + this._initialization.resolve() }) .catch((err: unknown) => { this._initializationError = err instanceof Error ? err.message : String(err) this._initState = 'failed' - this._initialization?.reject(err) + this._initialization.reject(err) }) } diff --git a/apps/backend/src/lib/servers/index.ts b/apps/backend/src/lib/servers/index.ts index 3b27eb1..dae7046 100644 --- a/apps/backend/src/lib/servers/index.ts +++ b/apps/backend/src/lib/servers/index.ts @@ -1,9 +1,10 @@ -import type { AppConfig, ServerConfig } from '../config' +import type { PeerConfig, ServerConfig } from '../config' import type { ArrServerConnector } from './arr/base' import type { ServerConnector } from './base' import { logger } from '../../logger' import { RadarrServerConnector } from './arr/radarr' import { SonarrServerConnector } from './arr/sonarr' +import { generateId } from './base' import { PeerConnector } from './peer' const serverConnectorMap = { @@ -16,31 +17,114 @@ export function getServerConnector(config: ServerConfig): ArrServerConnector { return new Connector(config) } -export function getConnectors(config: Pick) { - const servers = config.servers.map(getServerConnector) - const peers = config.peers.map(peer => new PeerConnector(peer)) - return { servers, peers } +async function initializeConnector(connector: ServerConnector) { + if (connector.isInitialized) + return + + connector.init() + await connector.initialization + .then(() => { + logger.debug({ connector: { name: connector.name, url: connector.url } }, `Initialized connector ${connector.name}`) + })! + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + logger.error({ error, connector: { name: connector.name, url: connector.url } }, `Failed to initialize connector ${connector.name}: ${message}`) + }) } +export class ConnectorManager { + private readonly _serverMap: Map = new Map() + private readonly _peerMap: Map = new Map() + private _destinationIds: string[] = [] + private _sourceIds: string[] = [] + + constructor(servers: ServerConfig[], peers: PeerConfig[]) { + logger.debug('Loading connectors from config') + + for (const serverConfig of servers) { + const id = generateId(serverConfig.url) + const connector = getServerConnector(serverConfig) + this._serverMap.set(id, connector) + if (connector.canDestination) { + this._destinationIds.push(connector.id) + } + if (connector.canSource) { + this._sourceIds.push(connector.id) + } + } + + logger.debug(`${this._serverMap.size} servers loaded`) + + for (const peerConfig of peers) { + const id = generateId(peerConfig.url) + const connector = new PeerConnector(peerConfig) + this._peerMap.set(id, connector) + } + + logger.debug(`${this._peerMap.size} peers loaded`) + } + + private getConnector(id: string): ServerConnector | undefined { + return this._serverMap.get(id) ?? this._peerMap.get(id) + } + + public get servers() { + return this._serverMap.values().toArray().filter(c => c.enabled) + } + + public get peers() { + return this._peerMap.values().toArray().filter(c => c.enabled) + } + + public get destinations() { + // Also gate on the CURRENT capability so a server toggled destination:false on + // update (Phase 5) drops out even if its id is still in the list. + return this._destinationIds + .map(id => this._serverMap.get(id)) + .filter((c): c is ArrServerConnector => Boolean(c?.enabled && c.canDestination)) + } + + public get sources() { + return this._sourceIds + .map(id => this._serverMap.get(id)) + .filter((c): c is ArrServerConnector => Boolean(c?.enabled && c.canSource)) + } + + public get connectors() { + return [...this._serverMap.values(), ...this._peerMap.values()].filter(c => c.enabled) + } + + public async initAll() { + await Promise.allSettled( + this.connectors.map(async (connector) => { + logger.info({ connector: { name: connector.name, url: connector.url } }, `Initializing connector ${connector.name}`) + await initializeConnector(connector) + }), + ) + } + + public async addServerConnector(config: ServerConfig) { + const connector = getServerConnector(config) + this._serverMap.set(connector.id, connector) + connector.init() + + await initializeConnector(connector) + } + + public async addPeerConnector(config: PeerConfig) { + const connector = new PeerConnector(config) + this._peerMap.set(connector.id, connector) + + await initializeConnector(connector) + } + + public removeConnector(id: string) { + const connector = this.getConnector(id) + + if (!connector) { + logger.info({ id }, 'Cannot disable connector because it was not found') + return + } -export async function initializeConnectors(config: Pick) { - const connectors = getConnectors(config) - const allConnectors: ServerConnector[] = [...connectors.servers, ...connectors.peers] - logger.debug(`Found ${allConnectors.length} connectors. Initializing...`) - - await Promise.all( - allConnectors.map(async (connector) => { - logger.info({ connector: { name: connector.name, url: connector.url } }, `Initializing connector ${connector.name}`) - connector.init() - await connector.initialization! - .then(() => { - logger.debug({ connector: { name: connector.name, url: connector.url } }, `Initialized connector ${connector.name}`) - })! - .catch((error) => { - const message = error instanceof Error ? error.message : String(error) - logger.error({ error, connector: { name: connector.name, url: connector.url } }, `Failed to initialize connector ${connector.name}: ${message}`) - }) - }), - ) - - return connectors + connector.disable() + } } diff --git a/apps/backend/src/middleware/handle-error.ts b/apps/backend/src/middleware/handle-error.ts index 1d77c60..ff2a5e5 100644 --- a/apps/backend/src/middleware/handle-error.ts +++ b/apps/backend/src/middleware/handle-error.ts @@ -5,12 +5,14 @@ import { xml } from '../helpers/xml' import { BadRequestError } from '../lib/errors/BadRequestError' import { ConflictError } from '../lib/errors/ConflictError' import { FetchError } from '../lib/errors/FetchError' +import { NotFoundError } from '../lib/errors/NotFoundError' import { UnauthorizedError } from '../lib/errors/UnauthorizedError' const STATUS_CODE_MAP = [ [UnauthorizedError, 401] as const, [BadRequestError, 400] as const, [ConflictError, 409] as const, + [NotFoundError, 404] as const, [FetchError, 503] as const, ] diff --git a/apps/backend/src/modules/config/config.controller.ts b/apps/backend/src/modules/config/config.controller.ts index e0f5fcd..dc72495 100644 --- a/apps/backend/src/modules/config/config.controller.ts +++ b/apps/backend/src/modules/config/config.controller.ts @@ -58,4 +58,25 @@ export class ConfigController { } return { ok: true } } + + async removePeer(id: string) { + if (!this.configService) + throw new Error('Config mutations require a configured ConfigService') + await this.configService.removePeer(id) + return { ok: true } + } + + async updatePeer(id: string, input: unknown) { + if (!this.configService) + throw new Error('Config mutations require a configured ConfigService') + try { + await this.configService.updatePeer(id, input) + } + catch (err) { + if (err instanceof z.ZodError) + throw new BadRequestError(z.prettifyError(err)) + throw err + } + return { ok: true } + } } diff --git a/apps/backend/src/modules/config/config.router.ts b/apps/backend/src/modules/config/config.router.ts index 3ee7615..913c537 100644 --- a/apps/backend/src/modules/config/config.router.ts +++ b/apps/backend/src/modules/config/config.router.ts @@ -13,5 +13,14 @@ export function getConfigRouter(controller: ConfigController) { return c.json(await controller.addPeer(body), 201) }) + app.delete('/peers/:id', async (c) => { + return c.json(await controller.removePeer(c.req.param('id'))) + }) + + app.patch('/peers/:id', async (c) => { + const body = await c.req.json().catch(() => null) + return c.json(await controller.updatePeer(c.req.param('id'), body)) + }) + return app } diff --git a/apps/backend/src/modules/config/config.service.ts b/apps/backend/src/modules/config/config.service.ts index 02da5b0..8ba5ada 100644 --- a/apps/backend/src/modules/config/config.service.ts +++ b/apps/backend/src/modules/config/config.service.ts @@ -4,7 +4,10 @@ import type { ConnectorManager } from '../../lib/servers' import { jsonc } from 'jsonc' import { atomicWriteFile } from '../../lib/atomic-write' import { PeerConfig, RawPeerConfig } from '../../lib/config' +import { BadRequestError } from '../../lib/errors/BadRequestError' import { ConflictError } from '../../lib/errors/ConflictError' +import { NotFoundError } from '../../lib/errors/NotFoundError' +import { generateId } from '../../lib/servers/base' type RawConfig = z.input type RawPeer = RawPeerConfig @@ -69,4 +72,53 @@ export class ConfigService { await this.#connectorManager.addPeerConnector(resolved) }) } + + #findPeerIndexById(peers: RawPeer[], id: string): number { + return peers.findIndex(p => generateId(p.url) === id) + } + + async removePeer(id: string): Promise { + return this.#enqueue(async () => { + const peers = (this.#raw.peers ?? []) as RawPeer[] + const index = this.#findPeerIndexById(peers, id) + if (index === -1) + throw new NotFoundError(`No peer found with id "${id}"`) + + // File is the source of truth: persist the file WITHOUT the peer first, commit + // in-memory, then disable the live connector. It stays resident (disabled) so + // in-flight downloads holding its reference finish; new fan-outs skip it; + // restart prunes it. + const next: RawConfig = { ...this.#raw, peers: peers.filter((_, i) => i !== index) } + await this.#persist(next) + this.#raw = next + this.#connectorManager.removeConnector(id) + }) + } + + async updatePeer(id: string, input: unknown): Promise { + const resolved = PeerConfig.parse(input) + const rawPeer = RawPeerConfig.parse(input) // strip unknown keys, keep refs + const newId = generateId(resolved.url) + + return this.#enqueue(async () => { + const peers = (this.#raw.peers ?? []) as RawPeer[] + const index = this.#findPeerIndexById(peers, id) + if (index === -1) + throw new NotFoundError(`No peer found with id "${id}"`) + + // URL change re-derives the id (rekey + download cascade) — Phase 4. + if (newId !== id) + throw new BadRequestError('Changing a peer URL is not supported yet') + + if (peers.some((p, i) => i !== index && p.name === resolved.name)) + throw new ConflictError(`A peer named "${resolved.name}" already exists`) + + const next: RawConfig = { ...this.#raw, peers: peers.map((p, i) => (i === index ? rawPeer : p)) } + await this.#persist(next) + this.#raw = next + // Same id → addPeerConnector overwrites the map entry and re-inits. The old + // instance is dropped from the map; any in-flight download holding it finishes. + await this.#connectorManager.addPeerConnector(resolved) + }) + } } From e151ac27dae68041b4499c9861958332d5b75085 Mon Sep 17 00:00:00 2001 From: Roz Date: Sun, 14 Jun 2026 22:23:41 +0200 Subject: [PATCH 11/22] feat: peer URL-change rekey + download peer_id cascade 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). --- .../src/__tests__/config-management.test.ts | 56 ++++++++++++++++++- .../__tests__/downloads-repository.test.ts | 49 ++++++++++++++++ apps/backend/src/index.ts | 8 +-- .../src/modules/config/config.service.ts | 36 ++++++++---- .../modules/downloads/downloads.repository.ts | 8 +++ 5 files changed, 140 insertions(+), 17 deletions(-) create mode 100644 apps/backend/src/__tests__/downloads-repository.test.ts diff --git a/apps/backend/src/__tests__/config-management.test.ts b/apps/backend/src/__tests__/config-management.test.ts index 1ba3db0..c95d1a6 100644 --- a/apps/backend/src/__tests__/config-management.test.ts +++ b/apps/backend/src/__tests__/config-management.test.ts @@ -2,11 +2,15 @@ import type { Envs } from '../lib/envs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { Database } from 'bun:sqlite' import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { drizzle } from 'drizzle-orm/bun-sqlite' import { jsonc } from 'jsonc' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { getApp } from '../app' +import { runMigrations } from '../database/connection' +import * as schema from '../database/schema' import { AppConfig, MIGRATIONS } from '../lib/config' import { ConnectorManager } from '../lib/servers' import { generateId } from '../lib/servers/base' @@ -14,6 +18,7 @@ import { PeerConnector } from '../lib/servers/peer' import { PROTOCOL_VERSION } from '../lib/version' import { getManagementApp } from '../management-app' import { ConfigService } from '../modules/config/config.service' +import { DownloadsRepository } from '../modules/downloads/downloads.repository' const config = AppConfig.parse({ version: MIGRATIONS.length, @@ -83,15 +88,19 @@ describe('Management API auth', () => { const mswServer = setupServer( http.get('http://bob.test:3000/handshake', () => HttpResponse.json({ name: 'jack', version: PROTOCOL_VERSION })), + http.get('http://bob2.test:3000/handshake', () => HttpResponse.json({ name: 'jack', version: PROTOCOL_VERSION })), http.get('http://carol.test:3000/handshake', () => HttpResponse.json({ name: 'jack', version: PROTOCOL_VERSION })), ) beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' })) afterAll(() => mswServer.close()) const tempFiles: string[] = [] +const dbsToClose: Database[] = [] afterEach(async () => { for (const f of tempFiles.splice(0)) await rm(f, { force: true }) + for (const db of dbsToClose.splice(0)) + db.close() }) // `app` is the MANAGEMENT app (where /config lives); `mainApp` is the public peer @@ -102,10 +111,16 @@ async function makeMutableApp(managementKey = 'mgmt-secret') { tempFiles.push(path) await Bun.write(path, jsonc.stringify({ version: 1, peers: [], servers: [] }, { space: 2 })) const connectorManager = new ConnectorManager([], []) - const configService = await ConfigService.fromFile({ path, connectorManager }) + const database = new Database(':memory:') + dbsToClose.push(database) + database.exec('pragma foreign_keys = ON') + const db = drizzle({ client: database, schema }) + runMigrations(db) + const downloadsRepository = new DownloadsRepository(db) + const configService = await ConfigService.fromFile({ path, connectorManager, downloadsRepository }) const app = getManagementApp({ environment: 'test', managementKey, connectors: connectorManager, configService }) - const mainApp = getApp(makeEnvs(managementKey), config, connectorManager) - return { app, mainApp, path, connectorManager } + const mainApp = getApp(makeEnvs(managementKey), config, connectorManager, { downloadsRepository }) + return { app, mainApp, path, connectorManager, downloadsRepository, database } } const KEY = { 'X-Management-Key': 'mgmt-secret' } as const @@ -213,3 +228,38 @@ describe('Management API remove/update peer', () => { expect(res.status).toBe(404) }) }) + +describe('Management API updatePeer url change', () => { + test('rekeys the connector map and cascades download rows', async () => { + const { app, connectorManager, downloadsRepository } = await makeMutableApp() + const urlA = 'http://bob.test:3000' + const urlB = 'http://bob2.test:3000' + const idA = generateId(urlA) + const idB = generateId(urlB) + + await app.request('/config/peers', { method: 'POST', headers: { ...KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Bob', url: urlA, apiKey: 'k' }) }) + + const dl = downloadsRepository.create({ + torrentFilename: 'm.torrent', + peerId: idA, + peerName: 'Bob', + itemId: 'movie:1', + filename: 'm.mkv', + destPath: '/tmp/m.mkv', + partPath: '/tmp/m.mkv.part', + releaseSize: 1, + release: { id: 'r', title: 'm', filename: 'm.mkv', category: 2000, size: 1 } as any, + }) + + const res = await app.request(`/config/peers/${idA}`, { + method: 'PATCH', + headers: { ...KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Bob', url: urlB, apiKey: 'k' }), + }) + expect(res.status).toBe(200) + + expect(connectorManager.peers.some(p => p.id === idB)).toBe(true) + expect(connectorManager.peers.some(p => p.id === idA)).toBe(false) + expect(downloadsRepository.get(dl.id)?.peerId).toBe(idB) + }) +}) diff --git a/apps/backend/src/__tests__/downloads-repository.test.ts b/apps/backend/src/__tests__/downloads-repository.test.ts new file mode 100644 index 0000000..bc1a1ed --- /dev/null +++ b/apps/backend/src/__tests__/downloads-repository.test.ts @@ -0,0 +1,49 @@ +import { Database } from 'bun:sqlite' +import { afterEach, describe, expect, test } from 'bun:test' +import { drizzle } from 'drizzle-orm/bun-sqlite' +import { runMigrations } from '../database/connection' +import * as schema from '../database/schema' +import { DownloadsRepository } from '../modules/downloads/downloads.repository' + +const dbs: Database[] = [] +afterEach(() => { + for (const db of dbs.splice(0)) db.close() +}) + +function makeRepo() { + const database = new Database(':memory:') + dbs.push(database) + database.exec('pragma foreign_keys = ON') + const db = drizzle({ client: database, schema }) + runMigrations(db) + return new DownloadsRepository(db) +} + +function seed(repo: DownloadsRepository, peerId: string, filename: string) { + return repo.create({ + torrentFilename: `${filename}.torrent`, + peerId, + peerName: peerId, + itemId: 'movie:1', + filename, + destPath: `/tmp/${filename}`, + partPath: `/tmp/${filename}.part`, + releaseSize: 1, + release: { id: 'r', title: filename, filename, category: 2000, size: 1 } as any, + }) +} + +describe('DownloadsRepository.reassignPeerId', () => { + test('moves only the matching rows', () => { + const repo = makeRepo() + const a = seed(repo, 'oldid', 'a') + const b = seed(repo, 'oldid', 'b') + const c = seed(repo, 'other', 'c') + + repo.reassignPeerId('oldid', 'newid') + + expect(repo.get(a.id)?.peerId).toBe('newid') + expect(repo.get(b.id)?.peerId).toBe('newid') + expect(repo.get(c.id)?.peerId).toBe('other') + }) +}) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 39c47a2..0e2e065 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -31,15 +31,15 @@ const config = await getAppConfig(envs) const connectorManager = new ConnectorManager(config.servers, config.peers) await connectorManager.initAll() +const database = await openDatabase({ appConfigPath: envs.APP_CONFIG_PATH }) +const downloadsRepository = new DownloadsRepository(database.db) + // NOTE: Phase 6 replaces this independent read with the shared raw object returned // by getAppConfig (see Phase 6) so the service can't diverge from the loaded config. const configService = envs.MANAGEMENT_KEY - ? await ConfigService.fromFile({ path: envs.APP_CONFIG_PATH, connectorManager }) + ? await ConfigService.fromFile({ path: envs.APP_CONFIG_PATH, connectorManager, downloadsRepository }) : undefined -const database = await openDatabase({ appConfigPath: envs.APP_CONFIG_PATH }) -const downloadsRepository = new DownloadsRepository(database.db) - const downloadsService = config.downloads ? new DownloadsService(config.downloads, connectorManager, downloadsRepository) : undefined diff --git a/apps/backend/src/modules/config/config.service.ts b/apps/backend/src/modules/config/config.service.ts index 8ba5ada..221524d 100644 --- a/apps/backend/src/modules/config/config.service.ts +++ b/apps/backend/src/modules/config/config.service.ts @@ -1,10 +1,10 @@ import type { z } from 'zod' import type { AppConfig } from '../../lib/config' import type { ConnectorManager } from '../../lib/servers' +import type { DownloadsRepository } from '../downloads/downloads.repository' import { jsonc } from 'jsonc' import { atomicWriteFile } from '../../lib/atomic-write' import { PeerConfig, RawPeerConfig } from '../../lib/config' -import { BadRequestError } from '../../lib/errors/BadRequestError' import { ConflictError } from '../../lib/errors/ConflictError' import { NotFoundError } from '../../lib/errors/NotFoundError' import { generateId } from '../../lib/servers/base' @@ -16,21 +16,23 @@ export class ConfigService { #path: string #raw: RawConfig #connectorManager: ConnectorManager + #downloadsRepository?: DownloadsRepository // Serialized write queue: one async mutex every mutation chains onto, so file // read-modify-write + map mutation never interleave between concurrent calls. #queue: Promise = Promise.resolve() - constructor(params: { path: string, raw: RawConfig, connectorManager: ConnectorManager }) { + constructor(params: { path: string, raw: RawConfig, connectorManager: ConnectorManager, downloadsRepository?: DownloadsRepository }) { this.#path = params.path this.#raw = params.raw this.#connectorManager = params.connectorManager + this.#downloadsRepository = params.downloadsRepository } /** Load the raw (refs-intact) config object from disk to seed the service. */ - static async fromFile(params: { path: string, connectorManager: ConnectorManager }): Promise { + static async fromFile(params: { path: string, connectorManager: ConnectorManager, downloadsRepository?: DownloadsRepository }): Promise { const text = await Bun.file(params.path).text() const raw = jsonc.parse(text) as RawConfig - return new ConfigService({ path: params.path, raw, connectorManager: params.connectorManager }) + return new ConfigService({ path: params.path, raw, connectorManager: params.connectorManager, downloadsRepository: params.downloadsRepository }) } #enqueue(task: () => Promise): Promise { @@ -106,19 +108,33 @@ export class ConfigService { if (index === -1) throw new NotFoundError(`No peer found with id "${id}"`) - // URL change re-derives the id (rekey + download cascade) — Phase 4. - if (newId !== id) - throw new BadRequestError('Changing a peer URL is not supported yet') - + // Name must stay unique against every OTHER peer. if (peers.some((p, i) => i !== index && p.name === resolved.name)) throw new ConflictError(`A peer named "${resolved.name}" already exists`) const next: RawConfig = { ...this.#raw, peers: peers.map((p, i) => (i === index ? rawPeer : p)) } + + if (newId === id) { + // Same url → rename / re-key headers. addPeerConnector overwrites the map + // entry and re-inits; the old instance is dropped, any in-flight download + // holding it finishes. + await this.#persist(next) + this.#raw = next + await this.#connectorManager.addPeerConnector(resolved) + return + } + + // URL changed → the id moves. Reject collision with an existing peer's url. + if (peers.some((p, i) => i !== index && generateId(p.url) === newId)) + throw new ConflictError(`A peer with url "${resolved.url}" already exists`) + await this.#persist(next) this.#raw = next - // Same id → addPeerConnector overwrites the map entry and re-inits. The old - // instance is dropped from the map; any in-flight download holding it finishes. + // Add under the new id (init on the new url), then drain the old connector and + // cascade the download rows so they follow the peer to the new id. await this.#connectorManager.addPeerConnector(resolved) + this.#connectorManager.removeConnector(id) + this.#downloadsRepository?.reassignPeerId(id, newId) }) } } diff --git a/apps/backend/src/modules/downloads/downloads.repository.ts b/apps/backend/src/modules/downloads/downloads.repository.ts index 04ee487..3b890c3 100644 --- a/apps/backend/src/modules/downloads/downloads.repository.ts +++ b/apps/backend/src/modules/downloads/downloads.repository.ts @@ -175,6 +175,14 @@ export class DownloadsRepository { this.db.delete(downloads).where(eq(downloads.id, id)).run() } + /** Manual ON UPDATE CASCADE: move every download row from one peer id to another. */ + reassignPeerId(oldPeerId: string, newPeerId: string): void { + this.db.update(downloads) + .set({ peerId: newPeerId, updatedAt: nowIso() }) + .where(eq(downloads.peerId, oldPeerId)) + .run() + } + /** Stale `downloading` rows from a prior run, returned for active re-drive (no mutation). */ listStaleDownloads(): DownloadRecord[] { return this.db.select().from(downloads).where(eq(downloads.status, 'downloading')).all().map(toRecord) From 84a0f7bcb62dcd8f0c16c166f157ee084d04d9dd Mon Sep 17 00:00:00 2001 From: Roz Date: Sun, 14 Jun 2026 22:27:18 +0200 Subject: [PATCH 12/22] feat: live servers CRUD via management API 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. --- .../src/__tests__/config-management.test.ts | 32 ++++++++ apps/backend/src/lib/config.ts | 15 ++++ apps/backend/src/lib/servers/index.ts | 9 ++- .../src/modules/config/config.controller.ts | 35 +++++++++ .../src/modules/config/config.router.ts | 14 ++++ .../src/modules/config/config.service.ts | 74 ++++++++++++++++++- 6 files changed, 177 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/__tests__/config-management.test.ts b/apps/backend/src/__tests__/config-management.test.ts index c95d1a6..9470621 100644 --- a/apps/backend/src/__tests__/config-management.test.ts +++ b/apps/backend/src/__tests__/config-management.test.ts @@ -90,6 +90,7 @@ const mswServer = setupServer( http.get('http://bob.test:3000/handshake', () => HttpResponse.json({ name: 'jack', version: PROTOCOL_VERSION })), http.get('http://bob2.test:3000/handshake', () => HttpResponse.json({ name: 'jack', version: PROTOCOL_VERSION })), http.get('http://carol.test:3000/handshake', () => HttpResponse.json({ name: 'jack', version: PROTOCOL_VERSION })), + http.get('http://radarr-new.test:7878/api/v3/system/status', () => HttpResponse.json({ appName: 'Radarr', version: '4.0.0' })), ) beforeAll(() => mswServer.listen({ onUnhandledRequest: 'bypass' })) afterAll(() => mswServer.close()) @@ -229,6 +230,37 @@ describe('Management API remove/update peer', () => { }) }) +describe('Management API servers', () => { + const SERVER = { + name: 'Radarr', + url: 'http://radarr-new.test:7878', + apiKey: 'a'.repeat(32), + type: 'radarr', + source: true, + destination: true, + } + const serverId = generateId(SERVER.url) + + test('adds a server live and registers it as a source/destination', async () => { + const { app, path, connectorManager } = await makeMutableApp() + const res = await app.request('/config/servers', { method: 'POST', headers: { ...KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(SERVER) }) + expect(res.status).toBe(201) + + const onDisk = jsonc.parse(await Bun.file(path).text()) as { servers: unknown[] } + expect(onDisk.servers).toHaveLength(1) + expect(connectorManager.servers.some(s => s.id === serverId)).toBe(true) + expect(connectorManager.sources.some(s => s.id === serverId)).toBe(true) + }) + + test('removes a server', async () => { + const { app, connectorManager } = await makeMutableApp() + await app.request('/config/servers', { method: 'POST', headers: { ...KEY, 'Content-Type': 'application/json' }, body: JSON.stringify(SERVER) }) + const res = await app.request(`/config/servers/${serverId}`, { method: 'DELETE', headers: KEY }) + expect(res.status).toBe(200) + expect(connectorManager.servers).toHaveLength(0) + }) +}) + describe('Management API updatePeer url change', () => { test('rekeys the connector map and cascades download rows', async () => { const { app, connectorManager, downloadsRepository } = await makeMutableApp() diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index 2dc89f6..fa0c5ec 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -113,6 +113,21 @@ export const ServerConfig = z.object({ export type ServerConfig = z.infer +// Raw server for persistence: strip unknown keys from a management-client body while +// preserving {env}/{file} secret refs, mirroring RawPeerConfig. +export const RawServerConfig = z.object({ + name: z.string(), + url: z.url(), + apiKey: RawConfigSecret, + headers: z.record(z.string(), RawConfigSecret).optional(), + type: ServerType, + source: z.boolean().optional(), + destination: z.boolean().optional(), + autoregister: z.object({ enable: z.boolean().optional(), priority: z.number().int().optional() }).optional(), +}) + +export type RawServerConfig = z.infer + export const PeerConfig = z.object({ name: z.string(), url: z.url(), diff --git a/apps/backend/src/lib/servers/index.ts b/apps/backend/src/lib/servers/index.ts index dae7046..797117d 100644 --- a/apps/backend/src/lib/servers/index.ts +++ b/apps/backend/src/lib/servers/index.ts @@ -105,7 +105,14 @@ export class ConnectorManager { public async addServerConnector(config: ServerConfig) { const connector = getServerConnector(config) this._serverMap.set(connector.id, connector) - connector.init() + + // Reconcile: drop any prior entry for this id, then re-add per current caps. + this._destinationIds = this._destinationIds.filter(id => id !== connector.id) + this._sourceIds = this._sourceIds.filter(id => id !== connector.id) + if (connector.canDestination) + this._destinationIds.push(connector.id) + if (connector.canSource) + this._sourceIds.push(connector.id) await initializeConnector(connector) } diff --git a/apps/backend/src/modules/config/config.controller.ts b/apps/backend/src/modules/config/config.controller.ts index dc72495..bf17140 100644 --- a/apps/backend/src/modules/config/config.controller.ts +++ b/apps/backend/src/modules/config/config.controller.ts @@ -79,4 +79,39 @@ export class ConfigController { } return { ok: true } } + + async addServer(input: unknown) { + if (!this.configService) + throw new Error('Config mutations require a configured ConfigService') + try { + await this.configService.addServer(input) + } + catch (err) { + if (err instanceof z.ZodError) + throw new BadRequestError(z.prettifyError(err)) + throw err + } + return { ok: true } + } + + async removeServer(id: string) { + if (!this.configService) + throw new Error('Config mutations require a configured ConfigService') + await this.configService.removeServer(id) + return { ok: true } + } + + async updateServer(id: string, input: unknown) { + if (!this.configService) + throw new Error('Config mutations require a configured ConfigService') + try { + await this.configService.updateServer(id, input) + } + catch (err) { + if (err instanceof z.ZodError) + throw new BadRequestError(z.prettifyError(err)) + throw err + } + return { ok: true } + } } diff --git a/apps/backend/src/modules/config/config.router.ts b/apps/backend/src/modules/config/config.router.ts index 913c537..ee87288 100644 --- a/apps/backend/src/modules/config/config.router.ts +++ b/apps/backend/src/modules/config/config.router.ts @@ -22,5 +22,19 @@ export function getConfigRouter(controller: ConfigController) { return c.json(await controller.updatePeer(c.req.param('id'), body)) }) + app.post('/servers', async (c) => { + const body = await c.req.json().catch(() => null) + return c.json(await controller.addServer(body), 201) + }) + + app.delete('/servers/:id', async (c) => { + return c.json(await controller.removeServer(c.req.param('id'))) + }) + + app.patch('/servers/:id', async (c) => { + const body = await c.req.json().catch(() => null) + return c.json(await controller.updateServer(c.req.param('id'), body)) + }) + return app } diff --git a/apps/backend/src/modules/config/config.service.ts b/apps/backend/src/modules/config/config.service.ts index 221524d..30d9278 100644 --- a/apps/backend/src/modules/config/config.service.ts +++ b/apps/backend/src/modules/config/config.service.ts @@ -4,13 +4,14 @@ import type { ConnectorManager } from '../../lib/servers' import type { DownloadsRepository } from '../downloads/downloads.repository' import { jsonc } from 'jsonc' import { atomicWriteFile } from '../../lib/atomic-write' -import { PeerConfig, RawPeerConfig } from '../../lib/config' +import { PeerConfig, RawPeerConfig, RawServerConfig, ServerConfig } from '../../lib/config' import { ConflictError } from '../../lib/errors/ConflictError' import { NotFoundError } from '../../lib/errors/NotFoundError' import { generateId } from '../../lib/servers/base' type RawConfig = z.input type RawPeer = RawPeerConfig +type RawServer = RawServerConfig export class ConfigService { #path: string @@ -137,4 +138,75 @@ export class ConfigService { this.#downloadsRepository?.reassignPeerId(id, newId) }) } + + #findServerIndexById(servers: RawServer[], id: string): number { + return servers.findIndex(s => generateId(s.url) === id) + } + + async addServer(input: unknown): Promise { + const resolved = ServerConfig.parse(input) + const rawServer = RawServerConfig.parse(input) // strip unknown keys, keep refs + + return this.#enqueue(async () => { + const servers = (this.#raw.servers ?? []) as RawServer[] + if (servers.some(s => s.url === resolved.url)) + throw new ConflictError(`A server with url "${resolved.url}" already exists`) + if (servers.some(s => s.name === resolved.name)) + throw new ConflictError(`A server named "${resolved.name}" already exists`) + + const next: RawConfig = { ...this.#raw, servers: [...servers, rawServer] } + await this.#persist(next) + this.#raw = next + await this.#connectorManager.addServerConnector(resolved) + }) + } + + async removeServer(id: string): Promise { + return this.#enqueue(async () => { + const servers = (this.#raw.servers ?? []) as RawServer[] + const index = this.#findServerIndexById(servers, id) + if (index === -1) + throw new NotFoundError(`No server found with id "${id}"`) + + const next: RawConfig = { ...this.#raw, servers: servers.filter((_, i) => i !== index) } + await this.#persist(next) + this.#raw = next + this.#connectorManager.removeConnector(id) + }) + } + + async updateServer(id: string, input: unknown): Promise { + const resolved = ServerConfig.parse(input) + const rawServer = RawServerConfig.parse(input) // strip unknown keys, keep refs + const newId = generateId(resolved.url) + + return this.#enqueue(async () => { + const servers = (this.#raw.servers ?? []) as RawServer[] + const index = this.#findServerIndexById(servers, id) + if (index === -1) + throw new NotFoundError(`No server found with id "${id}"`) + if (servers.some((s, i) => i !== index && s.name === resolved.name)) + throw new ConflictError(`A server named "${resolved.name}" already exists`) + + const next: RawConfig = { ...this.#raw, servers: servers.map((s, i) => (i === index ? rawServer : s)) } + + if (newId === id) { + await this.#persist(next) + this.#raw = next + await this.#connectorManager.addServerConnector(resolved) + return + } + + if (servers.some((s, i) => i !== index && generateId(s.url) === newId)) + throw new ConflictError(`A server with url "${resolved.url}" already exists`) + + // URL change rekeys the connector. NOTE: the Jack indexer/download-client + // already registered in *arr still points at the old binding — re-registration + // requires a restart (documented). No download cascade: downloads key off peers. + await this.#persist(next) + this.#raw = next + await this.#connectorManager.addServerConnector(resolved) + this.#connectorManager.removeConnector(id) + }) + } } From df2b1f00758710557165cf2718d3d7ed98a1472d Mon Sep 17 00:00:00 2001 From: Roz Date: Sun, 14 Jun 2026 22:40:56 +0200 Subject: [PATCH 13/22] feat: config migration write-back with .bak; fix up-to-date-file boot 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 .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. --- .../src/__tests__/config-migration.test.ts | 47 +++++++++++++++++++ apps/backend/src/index.ts | 8 ++-- apps/backend/src/lib/config.ts | 24 +++++++--- 3 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/__tests__/config-migration.test.ts diff --git a/apps/backend/src/__tests__/config-migration.test.ts b/apps/backend/src/__tests__/config-migration.test.ts new file mode 100644 index 0000000..258f6b9 --- /dev/null +++ b/apps/backend/src/__tests__/config-migration.test.ts @@ -0,0 +1,47 @@ +import { rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, test } from 'bun:test' +import { jsonc } from 'jsonc' +import { getAppConfig, MIGRATIONS } from '../lib/config' + +const paths: string[] = [] +afterEach(async () => { + for (const p of paths.splice(0)) { + await rm(p, { force: true }) + await rm(`${p}.bak`, { force: true }) + await rm(`${p}.tmp`, { force: true }) + } +}) + +function tempPath() { + const p = join(tmpdir(), `jack-cfg-${Math.random().toString(36).slice(2)}.jsonc`) + paths.push(p) + return p +} + +describe('Config migration write-back', () => { + test('migrates a v0 file, backs it up, and persists', async () => { + const path = tempPath() + const original = jsonc.stringify({ version: 0, peers: [], servers: [] }, { space: 2 }) + await Bun.write(path, original) + + const { appConfig } = await getAppConfig({ APP_CONFIG_PATH: path }) + expect(appConfig.version).toBe(MIGRATIONS.length) + + expect(await Bun.file(`${path}.bak`).text()).toBe(original) + const reread = jsonc.parse(await Bun.file(path).text()) as { version: number } + expect(reread.version).toBe(MIGRATIONS.length) + }) + + test('leaves an up-to-date file untouched (no .bak)', async () => { + const path = tempPath() + const current = jsonc.stringify({ version: MIGRATIONS.length, peers: [], servers: [] }, { space: 2 }) + await Bun.write(path, current) + + await getAppConfig({ APP_CONFIG_PATH: path }) + + expect(await Bun.file(`${path}.bak`).exists()).toBe(false) + expect(await Bun.file(path).text()).toBe(current) + }) +}) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 0e2e065..5f62c58 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -26,7 +26,7 @@ logger.debug('Loading environment variables') const envs = getAppEnvs() logger.debug('Loading app config') -const config = await getAppConfig(envs) +const { appConfig: config, raw: rawConfig } = await getAppConfig(envs) const connectorManager = new ConnectorManager(config.servers, config.peers) await connectorManager.initAll() @@ -34,10 +34,10 @@ await connectorManager.initAll() const database = await openDatabase({ appConfigPath: envs.APP_CONFIG_PATH }) const downloadsRepository = new DownloadsRepository(database.db) -// NOTE: Phase 6 replaces this independent read with the shared raw object returned -// by getAppConfig (see Phase 6) so the service can't diverge from the loaded config. +// Seed the management service from the shared raw object returned by getAppConfig +// so the service's persisted state can never diverge from the loaded runtime config. const configService = envs.MANAGEMENT_KEY - ? await ConfigService.fromFile({ path: envs.APP_CONFIG_PATH, connectorManager, downloadsRepository }) + ? new ConfigService({ path: envs.APP_CONFIG_PATH, raw: rawConfig, connectorManager, downloadsRepository }) : undefined const downloadsService = config.downloads diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index fa0c5ec..413c714 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -6,6 +6,7 @@ import process from 'node:process' import { jsonc } from 'jsonc' import z from 'zod' import { logger } from '../logger' +import { atomicWriteFile } from './atomic-write' const TRAILING_LINE_ENDINGS = /[\r\n]+$/ @@ -225,7 +226,7 @@ async function createDefaultAppConfig(path: string) { } } -export async function getAppConfig({ APP_CONFIG_PATH }: Pick) { +export async function getAppConfig({ APP_CONFIG_PATH }: Pick): Promise<{ appConfig: AppConfig, raw: z.input }> { const configFileExists = await fs.exists(APP_CONFIG_PATH) if (!configFileExists) { @@ -234,20 +235,29 @@ export async function getAppConfig({ APP_CONFIG_PATH }: Pick logger.debug(`Validating app config`) - return AppConfig.parse(migratedConfig) + return { appConfig: AppConfig.parse(raw), raw } } From 9dc47072cd00821874dfebe2179f2faeb5224941 Mon Sep 17 00:00:00 2001 From: Roz Date: Sun, 14 Jun 2026 22:59:57 +0200 Subject: [PATCH 14/22] feat: live connector visibility for search fan-out 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. --- .../src/__tests__/config-management.test.ts | 44 +++++++++++++++++++ .../src/__tests__/connector-init.test.ts | 10 ++--- .../src/__tests__/peer-range-serving.test.ts | 2 +- apps/backend/src/app.ts | 37 ++++++++++------ .../src/modules/peer/peer.controller.ts | 6 +-- .../src/modules/torznab/download.router.ts | 4 +- .../src/modules/torznab/torznab.controller.ts | 9 ++-- 7 files changed, 83 insertions(+), 29 deletions(-) diff --git a/apps/backend/src/__tests__/config-management.test.ts b/apps/backend/src/__tests__/config-management.test.ts index 9470621..16d6956 100644 --- a/apps/backend/src/__tests__/config-management.test.ts +++ b/apps/backend/src/__tests__/config-management.test.ts @@ -88,6 +88,9 @@ describe('Management API auth', () => { const mswServer = setupServer( http.get('http://bob.test:3000/handshake', () => HttpResponse.json({ name: 'jack', version: PROTOCOL_VERSION })), + http.get('http://bob.test:3000/peer/search', () => HttpResponse.json({ items: [ + { id: 'b:movie:1', title: 'Bob.Movie.1080p', filename: 'Bob.Movie.1080p.mkv', category: 2000, size: 1, imdbId: 'tt1', tmdbId: 1 }, + ] })), http.get('http://bob2.test:3000/handshake', () => HttpResponse.json({ name: 'jack', version: PROTOCOL_VERSION })), http.get('http://carol.test:3000/handshake', () => HttpResponse.json({ name: 'jack', version: PROTOCOL_VERSION })), http.get('http://radarr-new.test:7878/api/v3/system/status', () => HttpResponse.json({ appName: 'Radarr', version: '4.0.0' })), @@ -261,6 +264,47 @@ describe('Management API servers', () => { }) }) +describe('Management API live visibility', () => { + test('a live-added peer is searchable via /torznab without restart', async () => { + // `app` = management app (/config); `mainApp` = public app (/torznab). Same + // in-process connectorManager, so a mutation on one is visible to the other. + const { app, mainApp } = await makeMutableApp() + + // Before: empty feed (queried on the PUBLIC app). + const before = await (await mainApp.request('/torznab/api?t=search&apikey=test-api-key')).text() + expect(before).not.toContain('Bob.Movie.1080p') + + // Add via the MANAGEMENT app. + await app.request('/config/peers', { method: 'POST', headers: { ...KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Bob', url: 'http://bob.test:3000', apiKey: 'k' }) }) + + // After add: the peer's catalog appears live on the public feed. + const after = await (await mainApp.request('/torznab/api?t=search&apikey=test-api-key')).text() + expect(after).toContain('Bob.Movie.1080p') + + // After remove: gone again. + const { generateId } = await import('../lib/servers/base') + await app.request(`/config/peers/${generateId('http://bob.test:3000')}`, { method: 'DELETE', headers: KEY }) + const removed = await (await mainApp.request('/torznab/api?t=search&apikey=test-api-key')).text() + expect(removed).not.toContain('Bob.Movie.1080p') + }) + + test('a live-added peer appears in GET /servers without restart', async () => { + // Covers the lazy-getter-OBJECT wiring (ServersController). Together with the + // /torznab test above (the () => Connector[] PROVIDER wiring), both Phase-7 + // wiring styles are exercised — ItemsController/QbittorrentController reuse the + // same object-getter pattern as ServersController. + const { app, mainApp } = await makeMutableApp() + + const before = await (await mainApp.request('/servers', { headers: { 'X-Api-Key': 'test-api-key' } })).json() as { peers: Array<{ name: string }> } + expect(before.peers.some(p => p.name === 'Bob')).toBe(false) + + await app.request('/config/peers', { method: 'POST', headers: { ...KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Bob', url: 'http://bob.test:3000', apiKey: 'k' }) }) + + const after = await (await mainApp.request('/servers', { headers: { 'X-Api-Key': 'test-api-key' } })).json() as { peers: Array<{ name: string }> } + expect(after.peers.some(p => p.name === 'Bob')).toBe(true) + }) +}) + describe('Management API updatePeer url change', () => { test('rekeys the connector map and cascades download rows', async () => { const { app, connectorManager, downloadsRepository } = await makeMutableApp() diff --git a/apps/backend/src/__tests__/connector-init.test.ts b/apps/backend/src/__tests__/connector-init.test.ts index d822391..6ce0f33 100644 --- a/apps/backend/src/__tests__/connector-init.test.ts +++ b/apps/backend/src/__tests__/connector-init.test.ts @@ -81,13 +81,13 @@ describe('connector init() state machine', () => { // 1st attempt: fails, pinged once. radarr.init() - await expect(radarr.initialization!).rejects.toThrow() + await expect(radarr.initialization).rejects.toThrow() expect(pings).toBe(1) expect(radarr.isInitialized).toBe(false) // Still down: a fresh call re-pings (retry). radarr.init() - await expect(radarr.initialization!).rejects.toThrow() + await expect(radarr.initialization).rejects.toThrow() expect(pings).toBe(2) // Recovered: retry succeeds. @@ -133,7 +133,7 @@ describe('search resilience + lazy retry', () => { http.get('http://broken.test/api/v3/system/status', () => HttpResponse.json({ appName: 'Radarr', version: '5.0' })), http.get('http://broken.test/api/v3/movie', () => new HttpResponse('boom', { status: 500 })), ) - const controller = new PeerController([makeRadarr('http://good.test'), makeRadarr('http://broken.test')]) + const controller = new PeerController(() => [makeRadarr('http://good.test'), makeRadarr('http://broken.test')]) const results = await controller.search({}) @@ -150,7 +150,7 @@ describe('search resilience + lazy retry', () => { const up = makeRadarr('http://up.test') const down = makeRadarr('http://down.test') // Neither has been initialized — the old code would filter both out. - const controller = new PeerController([up, down]) + const controller = new PeerController(() => [up, down]) const results = await controller.search({}) @@ -168,7 +168,7 @@ describe('search resilience + lazy retry', () => { http.get('http://flaky.test/api/v3/movie', () => HttpResponse.json([mockMovie])), ) const flaky = makeRadarr('http://flaky.test') - const controller = new PeerController([flaky]) + const controller = new PeerController(() => [flaky]) // Down at boot → first search gets nothing. expect(await controller.search({})).toHaveLength(0) diff --git a/apps/backend/src/__tests__/peer-range-serving.test.ts b/apps/backend/src/__tests__/peer-range-serving.test.ts index 77d009f..22fe46d 100644 --- a/apps/backend/src/__tests__/peer-range-serving.test.ts +++ b/apps/backend/src/__tests__/peer-range-serving.test.ts @@ -43,7 +43,7 @@ function controllerForFile() { canSource: true, getFilePath: async () => filePath, } - return new PeerController([source as any]) + return new PeerController(() => [source as any]) } // `body` is now a BunFile/Blob (served via Bun's native backpressure) rather than a diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 0d72e25..7c91801 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -1,7 +1,6 @@ import type { AppConfig } from './lib/config' import type { Envs } from './lib/envs' -import type { ArrServerConnector } from './lib/servers/arr/base' -import type { PeerConnector } from './lib/servers/peer' +import type { ConnectorManager } from './lib/servers' import type { DownloadsRepository } from './modules/downloads/downloads.repository' import type { DownloadsService } from './modules/downloads/downloads.service' import { httpInstrumentationMiddleware } from '@hono/otel' @@ -26,23 +25,33 @@ import { getDownloadRouter } from './modules/torznab/download.router' import { TorznabController } from './modules/torznab/torznab.controller' import { getTorznabRouter } from './modules/torznab/torznab.router' -interface Connectors { - servers: ArrServerConnector[] - peers: PeerConnector[] -} - interface AppServices { downloadsRepository?: DownloadsRepository downloadsService?: DownloadsService } -export function getApp(envs: Envs, config: AppConfig, connectors: Connectors, services: AppServices = {}) { +export function getApp(envs: Envs, config: AppConfig, connManager: ConnectorManager, services: AppServices = {}) { const app = new Hono() + const connectors = { + get servers() { + return connManager.servers + }, + + get peers() { + return connManager.peers + }, + } // Controllers - const serversController = new ServersController({ servers: connectors.servers, peers: connectors.peers }) - const itemsController = new ItemsController({ sources: connectors.servers }) - const peerController = new PeerController(connectors.servers) + // ServersController takes { servers, peers } — the live wrapper satisfies it. + const serversController = new ServersController(connectors) + // ItemsController treats all servers as sources (unchanged semantics), read live. + const itemsController = new ItemsController({ + get sources() { + return connManager.servers + }, + }) + const peerController = new PeerController(() => connManager.servers) const downloadsController = services.downloadsRepository ? new DownloadsController(services.downloadsRepository) : null // Routers @@ -75,7 +84,7 @@ export function getApp(envs: Envs, config: AppConfig, connectors: Connectors, se const qbController = new QbittorrentController({ apiKey: config.jack.apiKey, completedPath: config.downloads.completedPath, - servers: connectors.servers, + get servers() { return connManager.servers }, repository: services.downloadsRepository, downloadsService: services.downloadsService, }) @@ -93,9 +102,9 @@ export function getApp(envs: Envs, config: AppConfig, connectors: Connectors, se if (config.jack) { const jackConfig = config.jack - const torznabController = new TorznabController(connectors.peers, jackConfig) + const torznabController = new TorznabController(() => connManager.peers, jackConfig) const torznabRouter = getTorznabRouter(torznabController) - const downloadRouter = getDownloadRouter(connectors.peers) + const downloadRouter = getDownloadRouter(() => connManager.peers) // Peer handshake — other Jacks probe this at init to read our identity and // protocol version, then check it against their minimum compatible version. diff --git a/apps/backend/src/modules/peer/peer.controller.ts b/apps/backend/src/modules/peer/peer.controller.ts index 7bff3e1..94e9428 100644 --- a/apps/backend/src/modules/peer/peer.controller.ts +++ b/apps/backend/src/modules/peer/peer.controller.ts @@ -46,7 +46,7 @@ export type StreamFileResult */ export class PeerController { constructor( - private readonly sources: ArrServerConnector[], + private readonly getSources: () => ArrServerConnector[], ) {} // Sources gated by config only (`source: true`). We deliberately do NOT filter @@ -54,7 +54,7 @@ export class PeerController { // attempted and re-initialized lazily by @requireInitialization, so one that // came back online rejoins searches without a restart. private get sourceServers() { - return this.sources.filter(s => s.canSource) + return this.getSources().filter(s => s.canSource) } async search(params: { imdbId?: string, tmdbId?: string, tvdbId?: string, season?: number, episode?: number }): Promise { @@ -64,7 +64,7 @@ export class PeerController { 'search.tvdb_id': params.tvdbId, 'search.season': params.season, 'search.episode': params.episode, - 'source.total_count': this.sources.length, + 'source.total_count': this.getSources().length, 'source.enabled_count': this.sourceServers.length, }, async (span) => { const sources = this.sourceServers diff --git a/apps/backend/src/modules/torznab/download.router.ts b/apps/backend/src/modules/torznab/download.router.ts index a186293..b59fb37 100644 --- a/apps/backend/src/modules/torznab/download.router.ts +++ b/apps/backend/src/modules/torznab/download.router.ts @@ -4,7 +4,7 @@ import { createTorrentStub } from './torrent' const TORRENT_EXTENSION_REGEX = /\.torrent$/ -export function getDownloadRouter(peers: PeerConnector[]) { +export function getDownloadRouter(getPeers: () => PeerConnector[]) { const app = new Hono() app.get('/download/:id', async (c) => { @@ -16,7 +16,7 @@ export function getDownloadRouter(peers: PeerConnector[]) { return c.json({ error: 'Invalid ID format, expected peerId:itemId' }, 400) } - const peer = peers.find(p => p.id === peerId) + const peer = getPeers().find(p => p.id === peerId) if (!peer || !peer.isInitialized) { return c.json({ error: 'Peer not found or not initialized' }, 404) } diff --git a/apps/backend/src/modules/torznab/torznab.controller.ts b/apps/backend/src/modules/torznab/torznab.controller.ts index ff14930..4dbf106 100644 --- a/apps/backend/src/modules/torznab/torznab.controller.ts +++ b/apps/backend/src/modules/torznab/torznab.controller.ts @@ -50,7 +50,7 @@ export function releaseToTorznab(release: Release, peerId: string, peerName: str export class TorznabController { constructor( - private readonly peers: PeerConnector[], + private readonly getPeers: () => PeerConnector[], private readonly jackConfig: NonNullable, ) {} @@ -60,17 +60,18 @@ export class TorznabController { // the call below, so a peer that came back online rejoins searches without a // restart. Each peer is isolated: if it fails (still down, or errors), we log // and treat it as zero results instead of failing the whole search. + const peers = this.getPeers() return withSpan('torznab.fan_out', { 'search.label': label, - 'peer.count': this.peers.length, + 'peer.count': peers.length, }, async (span) => { - if (this.peers.length === 0) { + if (peers.length === 0) { setSpanAttribute(span, 'release.count', 0) return [] } const results = await Promise.all( - this.peers.map(async (peer) => { + peers.map(async (peer) => { try { return await withSpan('torznab.peer_search', { 'search.label': label, From 0fdc75b92c94f12ab94f881baede39963f840285 Mon Sep 17 00:00:00 2001 From: Roz Date: Mon, 15 Jun 2026 00:15:17 +0200 Subject: [PATCH 15/22] refactor: dedupe config CRUD, gate mutation routes, harden atomic write - 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. --- .../src/__tests__/config-management.test.ts | 10 + apps/backend/src/lib/atomic-write.ts | 29 ++- apps/backend/src/lib/servers/index.ts | 12 + .../src/modules/config/config.controller.ts | 72 ++---- .../src/modules/config/config.router.ts | 58 ++--- .../src/modules/config/config.service.ts | 227 ++++++++---------- 6 files changed, 204 insertions(+), 204 deletions(-) diff --git a/apps/backend/src/__tests__/config-management.test.ts b/apps/backend/src/__tests__/config-management.test.ts index 16d6956..295f3f6 100644 --- a/apps/backend/src/__tests__/config-management.test.ts +++ b/apps/backend/src/__tests__/config-management.test.ts @@ -84,6 +84,16 @@ describe('Management API auth', () => { const res = await app.request('/config', { headers: { 'x-api-key': 'test-api-key' } }) expect(res.status).toBe(404) }) + + test('mutation routes are absent (404) when no ConfigService is wired', async () => { + // mgmtApp() injects no configService → canMutate is false → POST is unregistered. + const res = await mgmtApp().request('/config/peers', { + method: 'POST', + headers: { 'X-Management-Key': 'mgmt-secret', 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Bob', url: 'http://bob.test:3000', apiKey: 'k' }), + }) + expect(res.status).toBe(404) + }) }) const mswServer = setupServer( diff --git a/apps/backend/src/lib/atomic-write.ts b/apps/backend/src/lib/atomic-write.ts index 0bb3037..382b0a1 100644 --- a/apps/backend/src/lib/atomic-write.ts +++ b/apps/backend/src/lib/atomic-write.ts @@ -1,12 +1,27 @@ -import { rename } from 'node:fs/promises' +import { chmod, rename, stat, unlink } from 'node:fs/promises' /** - * Write `contents` to `path` atomically: write a sibling `.tmp` file then rename it - * over the target. rename(2) within a directory is atomic, so a reader never sees a - * half-written config and a crash mid-write leaves the original intact. + * Write `contents` to `path` atomically: write a uniquely-named sibling temp file + * then rename it over the target. rename(2) within a directory is atomic, so a + * reader never sees a half-written file and a crash mid-write leaves the original + * intact. + * + * - The temp name is randomized (not a fixed `.tmp`) to avoid clobbering/symlink + * races and concurrent-writer collisions. + * - Permissions are preserved from the existing target; a brand-new file defaults to + * owner-only (`0o600`) since config may carry secrets. + * - On any failure the temp file is cleaned up. */ export async function atomicWriteFile(path: string, contents: string): Promise { - const tmp = `${path}.tmp` - await Bun.write(tmp, contents) - await rename(tmp, path) + const tmp = `${path}.${crypto.randomUUID()}.tmp` + try { + await Bun.write(tmp, contents) + const mode = await stat(path).then(s => s.mode & 0o777).catch(() => 0o600) + await chmod(tmp, mode) + await rename(tmp, path) + } + catch (err) { + await unlink(tmp).catch(() => {}) + throw err + } } diff --git a/apps/backend/src/lib/servers/index.ts b/apps/backend/src/lib/servers/index.ts index 797117d..cb3e15a 100644 --- a/apps/backend/src/lib/servers/index.ts +++ b/apps/backend/src/lib/servers/index.ts @@ -124,6 +124,18 @@ export class ConnectorManager { await initializeConnector(connector) } + /** + * Soft-remove a connector: mark it disabled so every fan-out getter skips it, but + * keep the instance resident so any in-flight download holding its reference can + * finish on the still-live connector. + * + * Trade-off (intentional): disabled connectors are NOT evicted from the maps, so a + * long-lived process that churns many distinct-URL add/remove cycles accumulates + * dead connector instances until the next restart (which rebuilds the maps from the + * file and so prunes them). This is bounded by restart and acceptable for the + * expected usage (a small, slowly-changing set of peers/servers). If churn ever + * becomes high-volume, evict here once the connector reports no in-flight transfers. + */ public removeConnector(id: string) { const connector = this.getConnector(id) diff --git a/apps/backend/src/modules/config/config.controller.ts b/apps/backend/src/modules/config/config.controller.ts index bf17140..62e83ef 100644 --- a/apps/backend/src/modules/config/config.controller.ts +++ b/apps/backend/src/modules/config/config.controller.ts @@ -45,11 +45,19 @@ export class ConfigController { return { servers: this.connectors.servers.map(stringifyServer) } } - async addPeer(input: unknown) { + /** Whether mutation endpoints are available (a ConfigService was injected). */ + get canMutate() { + return this.configService !== undefined + } + + // Single funnel for every mutation: guarantees a service is present and maps a Zod + // validation failure to a 400. The router only mounts mutation routes when + // `canMutate`, so the guard here is defensive — direct callers still get a clear error. + async #mutate(run: (service: ConfigService) => Promise) { if (!this.configService) throw new Error('Config mutations require a configured ConfigService') try { - await this.configService.addPeer(input) + await run(this.configService) } catch (err) { if (err instanceof z.ZodError) @@ -59,59 +67,27 @@ export class ConfigController { return { ok: true } } - async removePeer(id: string) { - if (!this.configService) - throw new Error('Config mutations require a configured ConfigService') - await this.configService.removePeer(id) - return { ok: true } + addPeer(input: unknown) { + return this.#mutate(s => s.addPeer(input)) } - async updatePeer(id: string, input: unknown) { - if (!this.configService) - throw new Error('Config mutations require a configured ConfigService') - try { - await this.configService.updatePeer(id, input) - } - catch (err) { - if (err instanceof z.ZodError) - throw new BadRequestError(z.prettifyError(err)) - throw err - } - return { ok: true } + removePeer(id: string) { + return this.#mutate(s => s.removePeer(id)) } - async addServer(input: unknown) { - if (!this.configService) - throw new Error('Config mutations require a configured ConfigService') - try { - await this.configService.addServer(input) - } - catch (err) { - if (err instanceof z.ZodError) - throw new BadRequestError(z.prettifyError(err)) - throw err - } - return { ok: true } + updatePeer(id: string, input: unknown) { + return this.#mutate(s => s.updatePeer(id, input)) } - async removeServer(id: string) { - if (!this.configService) - throw new Error('Config mutations require a configured ConfigService') - await this.configService.removeServer(id) - return { ok: true } + addServer(input: unknown) { + return this.#mutate(s => s.addServer(input)) } - async updateServer(id: string, input: unknown) { - if (!this.configService) - throw new Error('Config mutations require a configured ConfigService') - try { - await this.configService.updateServer(id, input) - } - catch (err) { - if (err instanceof z.ZodError) - throw new BadRequestError(z.prettifyError(err)) - throw err - } - return { ok: true } + removeServer(id: string) { + return this.#mutate(s => s.removeServer(id)) + } + + updateServer(id: string, input: unknown) { + return this.#mutate(s => s.updateServer(id, input)) } } diff --git a/apps/backend/src/modules/config/config.router.ts b/apps/backend/src/modules/config/config.router.ts index ee87288..7d1a1cc 100644 --- a/apps/backend/src/modules/config/config.router.ts +++ b/apps/backend/src/modules/config/config.router.ts @@ -8,33 +8,37 @@ export function getConfigRouter(controller: ConfigController) { app.get('/peers', c => c.json(controller.listPeers())) app.get('/servers', c => c.json(controller.listServers())) - app.post('/peers', async (c) => { - const body = await c.req.json().catch(() => null) - return c.json(await controller.addPeer(body), 201) - }) - - app.delete('/peers/:id', async (c) => { - return c.json(await controller.removePeer(c.req.param('id'))) - }) - - app.patch('/peers/:id', async (c) => { - const body = await c.req.json().catch(() => null) - return c.json(await controller.updatePeer(c.req.param('id'), body)) - }) - - app.post('/servers', async (c) => { - const body = await c.req.json().catch(() => null) - return c.json(await controller.addServer(body), 201) - }) - - app.delete('/servers/:id', async (c) => { - return c.json(await controller.removeServer(c.req.param('id'))) - }) - - app.patch('/servers/:id', async (c) => { - const body = await c.req.json().catch(() => null) - return c.json(await controller.updateServer(c.req.param('id'), body)) - }) + // Mutation routes only exist when a ConfigService is wired in. Without one, these + // paths are simply unregistered → 404 (rather than a 500 from an unconfigured call). + if (controller.canMutate) { + app.post('/peers', async (c) => { + const body = await c.req.json().catch(() => null) + return c.json(await controller.addPeer(body), 201) + }) + + app.delete('/peers/:id', async (c) => { + return c.json(await controller.removePeer(c.req.param('id'))) + }) + + app.patch('/peers/:id', async (c) => { + const body = await c.req.json().catch(() => null) + return c.json(await controller.updatePeer(c.req.param('id'), body)) + }) + + app.post('/servers', async (c) => { + const body = await c.req.json().catch(() => null) + return c.json(await controller.addServer(body), 201) + }) + + app.delete('/servers/:id', async (c) => { + return c.json(await controller.removeServer(c.req.param('id'))) + }) + + app.patch('/servers/:id', async (c) => { + const body = await c.req.json().catch(() => null) + return c.json(await controller.updateServer(c.req.param('id'), body)) + }) + } return app } diff --git a/apps/backend/src/modules/config/config.service.ts b/apps/backend/src/modules/config/config.service.ts index 30d9278..5299719 100644 --- a/apps/backend/src/modules/config/config.service.ts +++ b/apps/backend/src/modules/config/config.service.ts @@ -10,8 +10,10 @@ import { NotFoundError } from '../../lib/errors/NotFoundError' import { generateId } from '../../lib/servers/base' type RawConfig = z.input -type RawPeer = RawPeerConfig -type RawServer = RawServerConfig +// Both peers and servers carry a plain-string `url` + `name` in their raw form — +// the only fields the generic add/remove/update helpers below need to reason about. +interface RawEntry { url: string, name: string } +type Slice = 'peers' | 'servers' export class ConfigService { #path: string @@ -29,7 +31,12 @@ export class ConfigService { this.#downloadsRepository = params.downloadsRepository } - /** Load the raw (refs-intact) config object from disk to seed the service. */ + /** + * Load the raw (refs-intact) config object from disk to seed the service. + * Production wiring (`index.ts`) instead passes the already-parsed `raw` object + * from `getAppConfig` to the constructor; this convenience factory is for + * standalone / test construction from just a path. + */ static async fromFile(params: { path: string, connectorManager: ConnectorManager, downloadsRepository?: DownloadsRepository }): Promise { const text = await Bun.file(params.path).text() const raw = jsonc.parse(text) as RawConfig @@ -51,162 +58,138 @@ export class ConfigService { await atomicWriteFile(this.#path, jsonc.stringify(next, { space: 2 })) } - async addPeer(input: unknown): Promise { - // Validate + resolve secrets up front: a bad shape or unresolvable {env}/{file} - // ref throws (→ 400) BEFORE any file/map mutation. We persist the ORIGINAL - // input (refs intact), not the resolved value. - const resolved = PeerConfig.parse(input) - // RawPeerConfig.parse strips unknown keys but preserves {env}/{file} refs — this - // sanitized object (not the raw `input`) is what we persist. - const rawPeer = RawPeerConfig.parse(input) + #slice(slice: Slice): RawEntry[] { + return (this.#raw[slice] ?? []) as RawEntry[] + } - return this.#enqueue(async () => { - const peers = (this.#raw.peers ?? []) as RawPeer[] + #indexById(entries: RawEntry[], id: string): number { + return entries.findIndex(e => generateId(e.url) === id) + } - if (peers.some(p => p.url === resolved.url)) - throw new ConflictError(`A peer with url "${resolved.url}" already exists`) - if (peers.some(p => p.name === resolved.name)) - throw new ConflictError(`A peer named "${resolved.name}" already exists`) + // ── Generic CRUD over a config slice ───────────────────────────────────────── + // Each helper runs inside the serialized queue and follows the same rollback-safe + // order: build the candidate `next`, persist it, commit `this.#raw`, then reconcile + // the live connector map. `addConnector` instantiates the right connector type; + // `onRekey` lets peers cascade their download rows on a URL change (servers pass none). + + #addEntry(slice: Slice, label: string, resolved: RawEntry, rawEntry: unknown, addConnector: () => Promise): Promise { + return this.#enqueue(async () => { + const entries = this.#slice(slice) + if (entries.some(e => e.url === resolved.url)) + throw new ConflictError(`A ${label} with url "${resolved.url}" already exists`) + if (entries.some(e => e.name === resolved.name)) + throw new ConflictError(`A ${label} named "${resolved.name}" already exists`) - // Build the candidate, persist it, THEN commit in-memory + reconcile the map. - const next: RawConfig = { ...this.#raw, peers: [...peers, rawPeer] } + const next = { ...this.#raw, [slice]: [...entries, rawEntry] } as RawConfig await this.#persist(next) this.#raw = next - await this.#connectorManager.addPeerConnector(resolved) + await addConnector() }) } - #findPeerIndexById(peers: RawPeer[], id: string): number { - return peers.findIndex(p => generateId(p.url) === id) - } - - async removePeer(id: string): Promise { + #removeEntry(slice: Slice, label: string, id: string): Promise { return this.#enqueue(async () => { - const peers = (this.#raw.peers ?? []) as RawPeer[] - const index = this.#findPeerIndexById(peers, id) + const entries = this.#slice(slice) + const index = this.#indexById(entries, id) if (index === -1) - throw new NotFoundError(`No peer found with id "${id}"`) + throw new NotFoundError(`No ${label} found with id "${id}"`) - // File is the source of truth: persist the file WITHOUT the peer first, commit - // in-memory, then disable the live connector. It stays resident (disabled) so - // in-flight downloads holding its reference finish; new fan-outs skip it; - // restart prunes it. - const next: RawConfig = { ...this.#raw, peers: peers.filter((_, i) => i !== index) } + // File is the source of truth: persist without the entry first, commit, then + // disable the live connector. It stays resident (disabled) so in-flight + // downloads holding its reference finish; new fan-outs skip it; restart prunes it. + const next = { ...this.#raw, [slice]: entries.filter((_, i) => i !== index) } as RawConfig await this.#persist(next) this.#raw = next this.#connectorManager.removeConnector(id) }) } - async updatePeer(id: string, input: unknown): Promise { - const resolved = PeerConfig.parse(input) - const rawPeer = RawPeerConfig.parse(input) // strip unknown keys, keep refs + #updateEntry( + slice: Slice, + label: string, + id: string, + resolved: RawEntry, + rawEntry: unknown, + addConnector: () => Promise, + onRekey?: (oldId: string, newId: string) => void, + ): Promise { const newId = generateId(resolved.url) - return this.#enqueue(async () => { - const peers = (this.#raw.peers ?? []) as RawPeer[] - const index = this.#findPeerIndexById(peers, id) + const entries = this.#slice(slice) + const index = this.#indexById(entries, id) if (index === -1) - throw new NotFoundError(`No peer found with id "${id}"`) - - // Name must stay unique against every OTHER peer. - if (peers.some((p, i) => i !== index && p.name === resolved.name)) - throw new ConflictError(`A peer named "${resolved.name}" already exists`) - - const next: RawConfig = { ...this.#raw, peers: peers.map((p, i) => (i === index ? rawPeer : p)) } - - if (newId === id) { - // Same url → rename / re-key headers. addPeerConnector overwrites the map - // entry and re-inits; the old instance is dropped, any in-flight download - // holding it finishes. - await this.#persist(next) - this.#raw = next - await this.#connectorManager.addPeerConnector(resolved) - return - } + throw new NotFoundError(`No ${label} found with id "${id}"`) - // URL changed → the id moves. Reject collision with an existing peer's url. - if (peers.some((p, i) => i !== index && generateId(p.url) === newId)) - throw new ConflictError(`A peer with url "${resolved.url}" already exists`) + // Name must stay unique against every OTHER entry. + if (entries.some((e, i) => i !== index && e.name === resolved.name)) + throw new ConflictError(`A ${label} named "${resolved.name}" already exists`) + // A URL change re-derives the id; reject a collision with another entry's url + // before touching the file. + if (newId !== id && entries.some((e, i) => i !== index && generateId(e.url) === newId)) + throw new ConflictError(`A ${label} with url "${resolved.url}" already exists`) + + const next = { ...this.#raw, [slice]: entries.map((e, i) => (i === index ? rawEntry : e)) } as RawConfig await this.#persist(next) this.#raw = next - // Add under the new id (init on the new url), then drain the old connector and - // cascade the download rows so they follow the peer to the new id. - await this.#connectorManager.addPeerConnector(resolved) - this.#connectorManager.removeConnector(id) - this.#downloadsRepository?.reassignPeerId(id, newId) + + // Same url → addConnector overwrites the map entry under the stable id and + // re-inits. URL change → it lands under the new id; then drain the old + // connector and let peers cascade their download rows to the new id. + await addConnector() + if (newId !== id) { + this.#connectorManager.removeConnector(id) + onRekey?.(id, newId) + } }) } - #findServerIndexById(servers: RawServer[], id: string): number { - return servers.findIndex(s => generateId(s.url) === id) + // ── Peers ──────────────────────────────────────────────────────────────────── + + async addPeer(input: unknown): Promise { + // Validate + resolve secrets up front (bad shape / unresolvable ref → 400 before + // any write); persist the ref-preserving `RawPeerConfig` parse, not the resolved value. + const resolved = PeerConfig.parse(input) + const rawPeer = RawPeerConfig.parse(input) + return this.#addEntry('peers', 'peer', resolved, rawPeer, () => this.#connectorManager.addPeerConnector(resolved)) } - async addServer(input: unknown): Promise { - const resolved = ServerConfig.parse(input) - const rawServer = RawServerConfig.parse(input) // strip unknown keys, keep refs + async removePeer(id: string): Promise { + return this.#removeEntry('peers', 'peer', id) + } - return this.#enqueue(async () => { - const servers = (this.#raw.servers ?? []) as RawServer[] - if (servers.some(s => s.url === resolved.url)) - throw new ConflictError(`A server with url "${resolved.url}" already exists`) - if (servers.some(s => s.name === resolved.name)) - throw new ConflictError(`A server named "${resolved.name}" already exists`) + async updatePeer(id: string, input: unknown): Promise { + const resolved = PeerConfig.parse(input) + const rawPeer = RawPeerConfig.parse(input) + return this.#updateEntry( + 'peers', + 'peer', + id, + resolved, + rawPeer, + () => this.#connectorManager.addPeerConnector(resolved), + (oldId, newId) => this.#downloadsRepository?.reassignPeerId(oldId, newId), + ) + } - const next: RawConfig = { ...this.#raw, servers: [...servers, rawServer] } - await this.#persist(next) - this.#raw = next - await this.#connectorManager.addServerConnector(resolved) - }) + // ── Servers ────────────────────────────────────────────────────────────────── + + async addServer(input: unknown): Promise { + const resolved = ServerConfig.parse(input) + const rawServer = RawServerConfig.parse(input) + return this.#addEntry('servers', 'server', resolved, rawServer, () => this.#connectorManager.addServerConnector(resolved)) } async removeServer(id: string): Promise { - return this.#enqueue(async () => { - const servers = (this.#raw.servers ?? []) as RawServer[] - const index = this.#findServerIndexById(servers, id) - if (index === -1) - throw new NotFoundError(`No server found with id "${id}"`) - - const next: RawConfig = { ...this.#raw, servers: servers.filter((_, i) => i !== index) } - await this.#persist(next) - this.#raw = next - this.#connectorManager.removeConnector(id) - }) + return this.#removeEntry('servers', 'server', id) } async updateServer(id: string, input: unknown): Promise { + // NOTE: a URL change rekeys the connector but does NOT re-register the Jack + // indexer/download-client already bound in *arr (that needs a restart), and there + // is no download cascade (downloads key off peers, not servers) — so no onRekey. const resolved = ServerConfig.parse(input) - const rawServer = RawServerConfig.parse(input) // strip unknown keys, keep refs - const newId = generateId(resolved.url) - - return this.#enqueue(async () => { - const servers = (this.#raw.servers ?? []) as RawServer[] - const index = this.#findServerIndexById(servers, id) - if (index === -1) - throw new NotFoundError(`No server found with id "${id}"`) - if (servers.some((s, i) => i !== index && s.name === resolved.name)) - throw new ConflictError(`A server named "${resolved.name}" already exists`) - - const next: RawConfig = { ...this.#raw, servers: servers.map((s, i) => (i === index ? rawServer : s)) } - - if (newId === id) { - await this.#persist(next) - this.#raw = next - await this.#connectorManager.addServerConnector(resolved) - return - } - - if (servers.some((s, i) => i !== index && generateId(s.url) === newId)) - throw new ConflictError(`A server with url "${resolved.url}" already exists`) - - // URL change rekeys the connector. NOTE: the Jack indexer/download-client - // already registered in *arr still points at the old binding — re-registration - // requires a restart (documented). No download cascade: downloads key off peers. - await this.#persist(next) - this.#raw = next - await this.#connectorManager.addServerConnector(resolved) - this.#connectorManager.removeConnector(id) - }) + const rawServer = RawServerConfig.parse(input) + return this.#updateEntry('servers', 'server', id, resolved, rawServer, () => this.#connectorManager.addServerConnector(resolved)) } } From 1f1b1435dcd077807fb671e1e4a7a8e6c0b9ac34 Mon Sep 17 00:00:00 2001 From: Roz Date: Mon, 15 Jun 2026 00:28:11 +0200 Subject: [PATCH 16/22] refactor: complete connector-lifecycle refactor; green the branch 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. --- .../src/__tests__/downloads-service.test.ts | 20 ++++++------- .../src/__tests__/peer-download.test.ts | 7 ++++- .../src/__tests__/peer-handshake.test.ts | 10 +++---- apps/backend/src/app.ts | 5 +++- ...lization.ts => requires-initialization.ts} | 4 +-- apps/backend/src/lib/servers/arr/base.ts | 29 ++++++++++--------- apps/backend/src/lib/servers/base.ts | 4 +-- apps/backend/src/lib/servers/peer.ts | 14 ++++----- .../modules/downloads/downloads.service.ts | 11 +++++-- 9 files changed, 60 insertions(+), 44 deletions(-) rename apps/backend/src/lib/decorators/{require-initialization.ts => requires-initialization.ts} (95%) diff --git a/apps/backend/src/__tests__/downloads-service.test.ts b/apps/backend/src/__tests__/downloads-service.test.ts index 23cd17f..e3660f0 100644 --- a/apps/backend/src/__tests__/downloads-service.test.ts +++ b/apps/backend/src/__tests__/downloads-service.test.ts @@ -74,7 +74,7 @@ describe('DownloadsService download progress persistence', () => { await options.onProgress({ type: 'completed', downloadedBytes: 10, expectedBytes: 10 }) }, }) - const service = new DownloadsService(downloadsConfig(), [peer as any], repository) + const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) await waitForStatus(repository, 'import_queued') @@ -98,7 +98,7 @@ describe('DownloadsService download progress persistence', () => { const peer = fakePeer({ getRelease: async () => { throw new Error('metadata failed') } }) - const service = new DownloadsService(downloadsConfig(), [peer as any], repository) + const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) const result = await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) @@ -115,7 +115,7 @@ describe('DownloadsService download progress persistence', () => { calls++ throw new FetchError('not found', new Response(null, { status: 404 })) } }) - const service = new DownloadsService(downloadsConfig(), [peer as any], repository) + const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) await waitForStatus(repository, 'failed') @@ -138,7 +138,7 @@ describe('DownloadsService download progress persistence', () => { await options.onProgress({ type: 'completed', downloadedBytes: 10, expectedBytes: 10 }) }, }) - const service = new DownloadsService(downloadsConfig(), [peer as any], repository) + const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) await waitForStatus(repository, 'import_queued') @@ -164,7 +164,7 @@ describe('DownloadsService download progress persistence', () => { await options.onProgress({ type: 'completed', downloadedBytes: 10, expectedBytes: 10 }) }, }) - const service = new DownloadsService(downloadsConfig(), [peer as any], repository) + const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) await waitForStatus(repository, 'import_queued') @@ -194,7 +194,7 @@ describe('DownloadsService download progress persistence', () => { await options.onProgress({ type: 'completed', downloadedBytes: 10, expectedBytes: 10 }) }, } - const service = new DownloadsService(downloadsConfig({ maxConcurrentDownloads: 1 }), [peer as any], repository) + const service = new DownloadsService(downloadsConfig({ maxConcurrentDownloads: 1 }), { peers: [peer as any] }, repository) await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:1', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) await service.startQbDownload({ peerId: 'peer-1', itemId: 'movie:2', qbCategory: 'jack-x', qbSourceServer: 'My Radarr' }) @@ -228,7 +228,7 @@ describe('DownloadsService download progress persistence', () => { releaseSize: release.size, release, }) - const service = new DownloadsService(downloadsConfig(), [peer as any], repository) + const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) const resumed = await service.resumeStaleDownloads() // resumeStaleDownloads fires in the background; wait for the row to settle. @@ -263,7 +263,7 @@ describe('DownloadsService download progress persistence', () => { } repository.create({ ...base, torrentFilename: 'first.torrent' }) repository.create({ ...base, torrentFilename: 'second.torrent' }) - const service = new DownloadsService(downloadsConfig(), [peer as any], repository) + const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) const resumed = await service.resumeStaleDownloads() for (let i = 0; i < 50 && !repository.list().some(d => d.status === 'import_queued'); i++) @@ -280,7 +280,7 @@ describe('DownloadsService download progress persistence', () => { test('startQbDownload creates a row with qb fields and ends import_queued', async () => { const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) const repository = new DownloadsRepository(handle.db) - const service = new DownloadsService(downloadsConfig(), [fakePeer() as any], repository) + const service = new DownloadsService(downloadsConfig(), { peers: [fakePeer() as any] }, repository) const result = await service.startQbDownload({ peerId: 'peer-1', @@ -304,7 +304,7 @@ describe('DownloadsService download progress persistence', () => { const handle = await openDatabase({ appConfigPath: join(tempDir, 'config.jsonc') }) const repository = new DownloadsRepository(handle.db) const peer = fakePeer({ getRelease: async () => ({ ...release, filename: '../../evil.mkv' }) }) - const service = new DownloadsService(downloadsConfig(), [peer as any], repository) + const service = new DownloadsService(downloadsConfig(), { peers: [peer as any] }, repository) const result = await service.startQbDownload({ peerId: 'peer-1', diff --git a/apps/backend/src/__tests__/peer-download.test.ts b/apps/backend/src/__tests__/peer-download.test.ts index 2e71304..638ee6f 100644 --- a/apps/backend/src/__tests__/peer-download.test.ts +++ b/apps/backend/src/__tests__/peer-download.test.ts @@ -20,7 +20,12 @@ afterEach(() => server.resetHandlers()) afterAll(() => server.close()) function markInitialized(connector: T): T { - ;(connector as any)._isInitialized = true + const c = connector as any + c._isInitialized = true + c._initState = 'initialized' + // The @requiresInitialization guard awaits the `initialization` PROMISE, so resolve + // it — otherwise every guarded call hangs waiting on an init the test skips. + c._initialization.resolve() return connector } diff --git a/apps/backend/src/__tests__/peer-handshake.test.ts b/apps/backend/src/__tests__/peer-handshake.test.ts index 4719805..bbfa245 100644 --- a/apps/backend/src/__tests__/peer-handshake.test.ts +++ b/apps/backend/src/__tests__/peer-handshake.test.ts @@ -42,7 +42,7 @@ describe('PeerConnector handshake compatibility', () => { ) const peer = makePeer() peer.init() - await peer.initialization?.catch(() => {}) + await peer.initialization.catch(() => {}) expect(peer.isInitialized).toBe(false) expect(peer.initializationError).toContain('incompatible peer-protocol version') @@ -55,7 +55,7 @@ describe('PeerConnector handshake compatibility', () => { ) const peer = makePeer() peer.init() - await peer.initialization?.catch(() => {}) + await peer.initialization.catch(() => {}) expect(peer.isInitialized).toBe(false) expect(peer.initializationError).toContain('got none') @@ -67,7 +67,7 @@ describe('PeerConnector handshake compatibility', () => { ) const peer = makePeer() peer.init() - await peer.initialization?.catch(() => {}) + await peer.initialization.catch(() => {}) expect(peer.isInitialized).toBe(false) expect(peer.initializationError).toContain('incompatible peer-protocol version') @@ -80,7 +80,7 @@ describe('PeerConnector handshake compatibility', () => { ) const peer = makePeer() peer.init() - await peer.initialization?.catch(() => {}) + await peer.initialization.catch(() => {}) expect(peer.isInitialized).toBe(false) expect(peer.initializationError).toContain('incompatible peer-protocol version') @@ -92,7 +92,7 @@ describe('PeerConnector handshake compatibility', () => { ) const peer = makePeer() peer.init() - await peer.initialization?.catch(() => {}) + await peer.initialization.catch(() => {}) expect(peer.isInitialized).toBe(false) expect(peer.initializationError).not.toContain('incompatible peer-protocol version') diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 7c91801..0bfb4f4 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -30,7 +30,10 @@ interface AppServices { downloadsService?: DownloadsService } -export function getApp(envs: Envs, config: AppConfig, connManager: ConnectorManager, services: AppServices = {}) { +// Only the live `servers`/`peers` getters are used here, so accept the structural +// shape a real `ConnectorManager` satisfies — this also lets tests pass a lightweight +// `{ servers, peers }` object. +export function getApp(envs: Envs, config: AppConfig, connManager: { servers: ConnectorManager['servers'], peers: ConnectorManager['peers'] }, services: AppServices = {}) { const app = new Hono() const connectors = { get servers() { diff --git a/apps/backend/src/lib/decorators/require-initialization.ts b/apps/backend/src/lib/decorators/requires-initialization.ts similarity index 95% rename from apps/backend/src/lib/decorators/require-initialization.ts rename to apps/backend/src/lib/decorators/requires-initialization.ts index 8040ed8..2c4f2cf 100644 --- a/apps/backend/src/lib/decorators/require-initialization.ts +++ b/apps/backend/src/lib/decorators/requires-initialization.ts @@ -11,11 +11,11 @@ import { logger } from '../../logger' * * @example * class MyConnector extends ArrServerConnector { - * @requireInitialization + * @requiresInitialization * async fetchData() { ... } * } */ -export function requireInitialization( +export function requiresInitialization( target: (...args: any[]) => any, context: ClassMethodDecoratorContext, ) { diff --git a/apps/backend/src/lib/servers/arr/base.ts b/apps/backend/src/lib/servers/arr/base.ts index e403e8d..823e8a1 100644 --- a/apps/backend/src/lib/servers/arr/base.ts +++ b/apps/backend/src/lib/servers/arr/base.ts @@ -1,9 +1,9 @@ -import type { AutoRegisterConfig, ConnectorHeadersConfig, ServerType } from '../../config' +import type { AutoRegisterConfig, ConnectorHeadersConfig, ServerConfig } from '../../config' import type { Release } from '../../release' import z from 'zod' import { logger } from '../../../logger' -import { requireInitialization } from '../../decorators/require-initialization' import { requiresDestination, requiresSource } from '../../decorators/requires-capability' +import { requiresInitialization } from '../../decorators/requires-initialization' import { ServerConnector } from '../base' const BASENAME_SEPARATOR_REGEX = /[/\\]/ @@ -68,7 +68,8 @@ export abstract class ArrServerConnector extends ServerConnector { constructor( connectorConfig: { pingPath: string, pingMethod: string, authHeader: string, expectedAppName: string }, - config: { type: ServerType, url: string, apiKey: string, name: string, source: boolean, destination: boolean, autoregister: AutoRegisterConfig, headers?: ConnectorHeadersConfig }, + // `headers` optional so subclasses/tests can omit it; the base defaults it to {}. + config: Omit & { headers?: ConnectorHeadersConfig }, ) { super(connectorConfig, config) this.expectedAppName = connectorConfig.expectedAppName @@ -112,44 +113,44 @@ export abstract class ArrServerConnector extends ServerConnector { // ---- Source role ---- @requiresSource - @requireInitialization + @requiresInitialization async searchItems(term: string): Promise { return this.doSearchItems(term) } @requiresSource - @requireInitialization + @requiresInitialization async searchByImdbId(imdbId: string): Promise { return this.doSearchByImdbId(imdbId) } @requiresSource - @requireInitialization + @requiresInitialization async searchByTmdbId(tmdbId: string): Promise { return this.doSearchByTmdbId(tmdbId) } @requiresSource - @requireInitialization + @requiresInitialization async searchByTvdbId(tvdbId: string, season?: number, episode?: number): Promise { return this.doSearchByTvdbId(tvdbId, season, episode) } /** All releases this source can serve — used for the torznab RSS/catalog feed. */ @requiresSource - @requireInitialization + @requiresInitialization async listReleases(): Promise { return this.doListReleases() } @requiresSource - @requireInitialization + @requiresInitialization async getRelease(id: string): Promise { return this.doGetRelease(id) } @requiresSource - @requireInitialization + @requiresInitialization async getFilePath(id: string): Promise { return this.doGetFilePath(id) } @@ -165,13 +166,13 @@ export abstract class ArrServerConnector extends ServerConnector { // ---- Destination role ---- @requiresDestination - @requireInitialization + @requiresInitialization async getHealthIssues() { return this.fetch('/api/v3/health', { schema: z.array(DestinationServerHealthIssue) }) } @requiresDestination - @requireInitialization + @requiresInitialization async registerIndexer(indexerConfig: { name: string, baseUrl: string, apiKey: string, priority: number, categories: number[], downloadClientId?: number }) { const existingIndexers = await this.arrGet('/api/v3/indexer') const existing: any = Array.isArray(existingIndexers) @@ -223,8 +224,8 @@ export abstract class ArrServerConnector extends ServerConnector { } @requiresDestination - @requireInitialization - async registerDownloadClient(clientConfig: { name: string, baseUrl: string, username: string, password: string, category: string }): Promise { + @requiresInitialization + public async registerDownloadClient(clientConfig: { name: string, baseUrl: string, username: string, password: string, category: string }): Promise { const url = new URL(clientConfig.baseUrl) const host = url.hostname const port = url.port ? Number(url.port) : (url.protocol === 'https:' ? 443 : 80) diff --git a/apps/backend/src/lib/servers/base.ts b/apps/backend/src/lib/servers/base.ts index 94eaff7..8466d90 100644 --- a/apps/backend/src/lib/servers/base.ts +++ b/apps/backend/src/lib/servers/base.ts @@ -1,4 +1,4 @@ -import type { ConnectorHeadersConfig, ConnectorType, ServerConfig } from '../config' +import type { ConnectorHeadersConfig, ConnectorType } from '../config' import z from 'zod' import { logger } from '../../logger' import { getAppEnvs } from '../envs' @@ -39,7 +39,7 @@ export abstract class ServerConnector { protected _initializationError: string | null = null protected _initState: 'idle' | 'pending' | 'initialized' | 'failed' = 'idle' - constructor(connectorConfig: { pingPath: string, pingMethod: string, authHeader: string, authHeaderPrefix?: string }, config: ServerConfig) { + constructor(connectorConfig: { pingPath: string, pingMethod: string, authHeader: string, authHeaderPrefix?: string }, config: { url: string, name: string, apiKey: string, type: ConnectorType, headers?: ConnectorHeadersConfig }) { this.pingPath = connectorConfig.pingPath this.pingMethod = connectorConfig.pingMethod this.authHeader = connectorConfig.authHeader diff --git a/apps/backend/src/lib/servers/peer.ts b/apps/backend/src/lib/servers/peer.ts index ff69872..0cabc35 100644 --- a/apps/backend/src/lib/servers/peer.ts +++ b/apps/backend/src/lib/servers/peer.ts @@ -2,7 +2,7 @@ import type { ConnectorHeadersConfig } from '../config' import { open, rename, unlink } from 'node:fs/promises' import z from 'zod' import { logger } from '../../logger' -import { requireInitialization } from '../decorators/require-initialization' +import { requiresInitialization } from '../decorators/requires-initialization' import { FetchError } from '../errors/FetchError' import { IdleTimeoutError } from '../errors/IdleTimeoutError' import { IncompatiblePeerError } from '../errors/IncompatiblePeerError' @@ -121,7 +121,7 @@ export class PeerConnector extends ServerConnector { }) } - @requireInitialization + @requiresInitialization async searchByImdbId(imdbId: string): Promise { return withSpan('peer.search_by_imdb', { 'peer.name': this.name, @@ -138,7 +138,7 @@ export class PeerConnector extends ServerConnector { }) } - @requireInitialization + @requiresInitialization async searchByTmdbId(tmdbId: string): Promise { return withSpan('peer.search_by_tmdb', { 'peer.name': this.name, @@ -153,7 +153,7 @@ export class PeerConnector extends ServerConnector { } /** Full catalog of the peer's releases (no filter) — used for the RSS feed. */ - @requireInitialization + @requiresInitialization async listReleases(): Promise { return withSpan('peer.catalog', { 'peer.name': this.name, @@ -165,7 +165,7 @@ export class PeerConnector extends ServerConnector { }) } - @requireInitialization + @requiresInitialization async searchByTvdbId(tvdbId: string, season?: number, episode?: number): Promise { return withSpan('peer.search_by_tvdb', { 'peer.name': this.name, @@ -189,12 +189,12 @@ export class PeerConnector extends ServerConnector { }) } - @requireInitialization + @requiresInitialization async getRelease(id: string): Promise { return this.fetch(`/peer/items/${encodeURIComponent(id)}`, { method: 'GET', schema: Release }) } - @requireInitialization + @requiresInitialization async downloadFile(id: string, destPath: string, options: PeerDownloadOptions = {}): Promise { return withSpan('peer.download_file', { 'peer.name': this.name, diff --git a/apps/backend/src/modules/downloads/downloads.service.ts b/apps/backend/src/modules/downloads/downloads.service.ts index 1afbcf2..7d260a9 100644 --- a/apps/backend/src/modules/downloads/downloads.service.ts +++ b/apps/backend/src/modules/downloads/downloads.service.ts @@ -1,5 +1,6 @@ import type { AppConfig } from '../../lib/config' -import type { PeerConnector, PeerDownloadProgressEvent } from '../../lib/servers/peer' +import type { ConnectorManager } from '../../lib/servers' +import type { PeerDownloadProgressEvent } from '../../lib/servers/peer' import type { DownloadRecord, DownloadsRepository } from './downloads.repository' import { basename, join } from 'node:path' import { retry } from '../../lib/retry' @@ -35,12 +36,18 @@ export class DownloadsService { constructor( private readonly config: DownloadsServiceConfig, - private readonly peers: PeerConnector[], + // Only the live `peers` getter is used; accept the structural shape so a real + // ConnectorManager (live) or a test stub both satisfy it. + private readonly connectorManager: { peers: ConnectorManager['peers'] }, private readonly downloadsRepository?: DownloadsRepository, ) { this.semaphore = new Semaphore(config.maxConcurrentDownloads) } + private get peers() { + return this.connectorManager.peers + } + /** * Shared creation core for the qB add path. Returns the created record, a * benign duplicate (a download for the same destination is already active), From 3060c85d5eac9674c15856a4e3d53ef55f967aa3 Mon Sep 17 00:00:00 2001 From: Roz Date: Wed, 17 Jun 2026 15:30:44 +0200 Subject: [PATCH 17/22] fix: scope version catch so config survives a downgrade 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. --- apps/backend/src/lib/config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index 413c714..5e7b028 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -184,8 +184,7 @@ const LATEST_MIGRATION = MIGRATIONS.length export function migrateConfig(rawConfigObject: unknown) { const configObject = z - .looseObject({ version: z.number().max(LATEST_MIGRATION).min(0).default(0) }) - .catch({ version: 0 }) + .looseObject({ version: z.number().max(LATEST_MIGRATION).min(0).default(0).catch(0) }) .parse(rawConfigObject) const currentVersion = configObject.version From 51ab3e6a32881d4bcdd9c710c1ee0931a8d65c0f Mon Sep 17 00:00:00 2001 From: Roz Date: Wed, 17 Jun 2026 15:30:49 +0200 Subject: [PATCH 18/22] fix: log management-port collision at error level, not fatal 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. --- apps/backend/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 5f62c58..531cd22 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -62,7 +62,7 @@ logger.info({ let managementServer: ReturnType | undefined if (envs.MANAGEMENT_KEY) { if (envs.MANAGEMENT_PORT === server.port) { - logger.fatal({ port: envs.MANAGEMENT_PORT }, 'MANAGEMENT_PORT collides with the public port; not starting the management API') + logger.error({ port: envs.MANAGEMENT_PORT }, 'MANAGEMENT_PORT collides with the public port; not starting the management API') } else { const managementApp = getManagementApp({ From 10cb7e7192febb21e952bcd47347063b4d75cf1e Mon Sep 17 00:00:00 2001 From: Roz Date: Wed, 17 Jun 2026 15:30:55 +0200 Subject: [PATCH 19/22] feat: seed request headers from JACK_HEADERS env in cli --- scripts/cli.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/cli.ts b/scripts/cli.ts index 8fa053d..b38e021 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -4,6 +4,7 @@ import process from 'node:process' // Base URL and API key come from the environment. const BASE_URL = process.env.JACK_URL ?? 'http://localhost:5225' const API_KEY = process.env.JACK_API_KEY ?? '' +const BASE_HEADERS = JSON.parse(process.env.JACK_HEADERS ?? '{}') const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']) @@ -43,7 +44,7 @@ interface ParsedItems { function parseItems(items: string[]): ParsedItems { const query: Record = {} - const headers: Record = {} + const headers: Record = BASE_HEADERS const body: Record = {} for (const item of items) { From a722fc40b672fcec9706620a14c6e2471493251f Mon Sep 17 00:00:00 2001 From: Roz Date: Thu, 18 Jun 2026 06:28:46 +0200 Subject: [PATCH 20/22] refactor: validate config mutation bodies with zod; flatten management-server startup --- apps/backend/src/index.ts | 31 +++++++++-------- .../src/modules/config/config.router.ts | 33 ++++++++++--------- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 531cd22..abc937b 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -58,24 +58,29 @@ logger.info({ destinations: connectorManager.destinations.length, }, 'Server listening') -// Module-scope so the SIGINT/SIGTERM handlers below can stop it too. -let managementServer: ReturnType | undefined -if (envs.MANAGEMENT_KEY) { +function startManagementServer() { + if (!envs.MANAGEMENT_KEY) + return undefined + if (envs.MANAGEMENT_PORT === server.port) { logger.error({ port: envs.MANAGEMENT_PORT }, 'MANAGEMENT_PORT collides with the public port; not starting the management API') + return undefined } - else { - const managementApp = getManagementApp({ - environment: envs.ENVIRONMENT, - managementKey: envs.MANAGEMENT_KEY, - connectors: connectorManager, - configService, - }) - managementServer = Bun.serve({ port: envs.MANAGEMENT_PORT, fetch: managementApp.fetch }) - logger.info({ port: managementServer.port }, 'Management API listening') - } + + const managementApp = getManagementApp({ + environment: envs.ENVIRONMENT, + managementKey: envs.MANAGEMENT_KEY, + connectors: connectorManager, + configService, + }) + const instance = Bun.serve({ port: envs.MANAGEMENT_PORT, fetch: managementApp.fetch }) + logger.info({ port: instance.port }, 'Management API listening') + return instance } +// Module-scope so the SIGINT/SIGTERM handlers below can stop it too. +const managementServer = startManagementServer() + // Auto-register as a Torznab indexer + qBittorrent download client in each // destination that opts in via its `autoregister` config. We register even when // there are no peers / an empty catalog (forceSave on the *arr side), so the diff --git a/apps/backend/src/modules/config/config.router.ts b/apps/backend/src/modules/config/config.router.ts index 7d1a1cc..0305978 100644 --- a/apps/backend/src/modules/config/config.router.ts +++ b/apps/backend/src/modules/config/config.router.ts @@ -1,5 +1,10 @@ import type { ConfigController } from './config.controller' import { Hono } from 'hono' +import { validator as zValidator } from 'hono-openapi' +import { z } from 'zod' +import { RawPeerConfig, RawServerConfig } from '../../lib/config' + +const idParam = z.object({ id: z.string().min(1) }) export function getConfigRouter(controller: ConfigController) { const app = new Hono() @@ -11,32 +16,28 @@ export function getConfigRouter(controller: ConfigController) { // Mutation routes only exist when a ConfigService is wired in. Without one, these // paths are simply unregistered → 404 (rather than a 500 from an unconfigured call). if (controller.canMutate) { - app.post('/peers', async (c) => { - const body = await c.req.json().catch(() => null) - return c.json(await controller.addPeer(body), 201) + app.post('/peers', zValidator('json', RawPeerConfig), async (c) => { + return c.json(await controller.addPeer(c.req.valid('json')), 201) }) - app.delete('/peers/:id', async (c) => { - return c.json(await controller.removePeer(c.req.param('id'))) + app.delete('/peers/:id', zValidator('param', idParam), async (c) => { + return c.json(await controller.removePeer(c.req.valid('param').id)) }) - app.patch('/peers/:id', async (c) => { - const body = await c.req.json().catch(() => null) - return c.json(await controller.updatePeer(c.req.param('id'), body)) + app.patch('/peers/:id', zValidator('param', idParam), zValidator('json', RawPeerConfig), async (c) => { + return c.json(await controller.updatePeer(c.req.valid('param').id, c.req.valid('json'))) }) - app.post('/servers', async (c) => { - const body = await c.req.json().catch(() => null) - return c.json(await controller.addServer(body), 201) + app.post('/servers', zValidator('json', RawServerConfig), async (c) => { + return c.json(await controller.addServer(c.req.valid('json')), 201) }) - app.delete('/servers/:id', async (c) => { - return c.json(await controller.removeServer(c.req.param('id'))) + app.delete('/servers/:id', zValidator('param', idParam), async (c) => { + return c.json(await controller.removeServer(c.req.valid('param').id)) }) - app.patch('/servers/:id', async (c) => { - const body = await c.req.json().catch(() => null) - return c.json(await controller.updateServer(c.req.param('id'), body)) + app.patch('/servers/:id', zValidator('param', idParam), zValidator('json', RawServerConfig), async (c) => { + return c.json(await controller.updateServer(c.req.valid('param').id, c.req.valid('json'))) }) } From b503d974451e345be8a8b377e33793a6c9ad7310 Mon Sep 17 00:00:00 2001 From: Roz Date: Thu, 18 Jun 2026 06:30:18 +0200 Subject: [PATCH 21/22] refactor: use private keyword instead of JS native private fields --- .../src/modules/config/config.controller.ts | 14 +-- .../src/modules/config/config.service.ts | 98 +++++++++---------- 2 files changed, 56 insertions(+), 56 deletions(-) diff --git a/apps/backend/src/modules/config/config.controller.ts b/apps/backend/src/modules/config/config.controller.ts index 62e83ef..a413924 100644 --- a/apps/backend/src/modules/config/config.controller.ts +++ b/apps/backend/src/modules/config/config.controller.ts @@ -53,7 +53,7 @@ export class ConfigController { // Single funnel for every mutation: guarantees a service is present and maps a Zod // validation failure to a 400. The router only mounts mutation routes when // `canMutate`, so the guard here is defensive — direct callers still get a clear error. - async #mutate(run: (service: ConfigService) => Promise) { + private async mutate(run: (service: ConfigService) => Promise) { if (!this.configService) throw new Error('Config mutations require a configured ConfigService') try { @@ -68,26 +68,26 @@ export class ConfigController { } addPeer(input: unknown) { - return this.#mutate(s => s.addPeer(input)) + return this.mutate(s => s.addPeer(input)) } removePeer(id: string) { - return this.#mutate(s => s.removePeer(id)) + return this.mutate(s => s.removePeer(id)) } updatePeer(id: string, input: unknown) { - return this.#mutate(s => s.updatePeer(id, input)) + return this.mutate(s => s.updatePeer(id, input)) } addServer(input: unknown) { - return this.#mutate(s => s.addServer(input)) + return this.mutate(s => s.addServer(input)) } removeServer(id: string) { - return this.#mutate(s => s.removeServer(id)) + return this.mutate(s => s.removeServer(id)) } updateServer(id: string, input: unknown) { - return this.#mutate(s => s.updateServer(id, input)) + return this.mutate(s => s.updateServer(id, input)) } } diff --git a/apps/backend/src/modules/config/config.service.ts b/apps/backend/src/modules/config/config.service.ts index 5299719..6836d0a 100644 --- a/apps/backend/src/modules/config/config.service.ts +++ b/apps/backend/src/modules/config/config.service.ts @@ -16,19 +16,19 @@ interface RawEntry { url: string, name: string } type Slice = 'peers' | 'servers' export class ConfigService { - #path: string - #raw: RawConfig - #connectorManager: ConnectorManager - #downloadsRepository?: DownloadsRepository + private path: string + private raw: RawConfig + private connectorManager: ConnectorManager + private downloadsRepository?: DownloadsRepository // Serialized write queue: one async mutex every mutation chains onto, so file // read-modify-write + map mutation never interleave between concurrent calls. - #queue: Promise = Promise.resolve() + private queue: Promise = Promise.resolve() constructor(params: { path: string, raw: RawConfig, connectorManager: ConnectorManager, downloadsRepository?: DownloadsRepository }) { - this.#path = params.path - this.#raw = params.raw - this.#connectorManager = params.connectorManager - this.#downloadsRepository = params.downloadsRepository + this.path = params.path + this.raw = params.raw + this.connectorManager = params.connectorManager + this.downloadsRepository = params.downloadsRepository } /** @@ -43,68 +43,68 @@ export class ConfigService { return new ConfigService({ path: params.path, raw, connectorManager: params.connectorManager, downloadsRepository: params.downloadsRepository }) } - #enqueue(task: () => Promise): Promise { - const run = this.#queue.then(task, task) + private enqueue(task: () => Promise): Promise { + const run = this.queue.then(task, task) // Swallow this task's result/error on the chain so a rejection doesn't poison // the next enqueued task; the original promise still rejects to the caller. - this.#queue = run.then(() => {}, () => {}) + this.queue = run.then(() => {}, () => {}) return run } // Rollback-safe persist: write the CANDIDATE raw to disk first; the caller only - // assigns `this.#raw = next` AFTER this resolves, so a failed write never leaves + // assigns `this.raw = next` AFTER this resolves, so a failed write never leaves // in-memory state diverged from the file. - async #persist(next: RawConfig): Promise { - await atomicWriteFile(this.#path, jsonc.stringify(next, { space: 2 })) + private async persist(next: RawConfig): Promise { + await atomicWriteFile(this.path, jsonc.stringify(next, { space: 2 })) } - #slice(slice: Slice): RawEntry[] { - return (this.#raw[slice] ?? []) as RawEntry[] + private slice(slice: Slice): RawEntry[] { + return (this.raw[slice] ?? []) as RawEntry[] } - #indexById(entries: RawEntry[], id: string): number { + private indexById(entries: RawEntry[], id: string): number { return entries.findIndex(e => generateId(e.url) === id) } // ── Generic CRUD over a config slice ───────────────────────────────────────── // Each helper runs inside the serialized queue and follows the same rollback-safe - // order: build the candidate `next`, persist it, commit `this.#raw`, then reconcile + // order: build the candidate `next`, persist it, commit `this.raw`, then reconcile // the live connector map. `addConnector` instantiates the right connector type; // `onRekey` lets peers cascade their download rows on a URL change (servers pass none). - #addEntry(slice: Slice, label: string, resolved: RawEntry, rawEntry: unknown, addConnector: () => Promise): Promise { - return this.#enqueue(async () => { - const entries = this.#slice(slice) + private addEntry(slice: Slice, label: string, resolved: RawEntry, rawEntry: unknown, addConnector: () => Promise): Promise { + return this.enqueue(async () => { + const entries = this.slice(slice) if (entries.some(e => e.url === resolved.url)) throw new ConflictError(`A ${label} with url "${resolved.url}" already exists`) if (entries.some(e => e.name === resolved.name)) throw new ConflictError(`A ${label} named "${resolved.name}" already exists`) - const next = { ...this.#raw, [slice]: [...entries, rawEntry] } as RawConfig - await this.#persist(next) - this.#raw = next + const next = { ...this.raw, [slice]: [...entries, rawEntry] } as RawConfig + await this.persist(next) + this.raw = next await addConnector() }) } - #removeEntry(slice: Slice, label: string, id: string): Promise { - return this.#enqueue(async () => { - const entries = this.#slice(slice) - const index = this.#indexById(entries, id) + private removeEntry(slice: Slice, label: string, id: string): Promise { + return this.enqueue(async () => { + const entries = this.slice(slice) + const index = this.indexById(entries, id) if (index === -1) throw new NotFoundError(`No ${label} found with id "${id}"`) // File is the source of truth: persist without the entry first, commit, then // disable the live connector. It stays resident (disabled) so in-flight // downloads holding its reference finish; new fan-outs skip it; restart prunes it. - const next = { ...this.#raw, [slice]: entries.filter((_, i) => i !== index) } as RawConfig - await this.#persist(next) - this.#raw = next - this.#connectorManager.removeConnector(id) + const next = { ...this.raw, [slice]: entries.filter((_, i) => i !== index) } as RawConfig + await this.persist(next) + this.raw = next + this.connectorManager.removeConnector(id) }) } - #updateEntry( + private updateEntry( slice: Slice, label: string, id: string, @@ -114,9 +114,9 @@ export class ConfigService { onRekey?: (oldId: string, newId: string) => void, ): Promise { const newId = generateId(resolved.url) - return this.#enqueue(async () => { - const entries = this.#slice(slice) - const index = this.#indexById(entries, id) + return this.enqueue(async () => { + const entries = this.slice(slice) + const index = this.indexById(entries, id) if (index === -1) throw new NotFoundError(`No ${label} found with id "${id}"`) @@ -129,16 +129,16 @@ export class ConfigService { if (newId !== id && entries.some((e, i) => i !== index && generateId(e.url) === newId)) throw new ConflictError(`A ${label} with url "${resolved.url}" already exists`) - const next = { ...this.#raw, [slice]: entries.map((e, i) => (i === index ? rawEntry : e)) } as RawConfig - await this.#persist(next) - this.#raw = next + const next = { ...this.raw, [slice]: entries.map((e, i) => (i === index ? rawEntry : e)) } as RawConfig + await this.persist(next) + this.raw = next // Same url → addConnector overwrites the map entry under the stable id and // re-inits. URL change → it lands under the new id; then drain the old // connector and let peers cascade their download rows to the new id. await addConnector() if (newId !== id) { - this.#connectorManager.removeConnector(id) + this.connectorManager.removeConnector(id) onRekey?.(id, newId) } }) @@ -151,24 +151,24 @@ export class ConfigService { // any write); persist the ref-preserving `RawPeerConfig` parse, not the resolved value. const resolved = PeerConfig.parse(input) const rawPeer = RawPeerConfig.parse(input) - return this.#addEntry('peers', 'peer', resolved, rawPeer, () => this.#connectorManager.addPeerConnector(resolved)) + return this.addEntry('peers', 'peer', resolved, rawPeer, () => this.connectorManager.addPeerConnector(resolved)) } async removePeer(id: string): Promise { - return this.#removeEntry('peers', 'peer', id) + return this.removeEntry('peers', 'peer', id) } async updatePeer(id: string, input: unknown): Promise { const resolved = PeerConfig.parse(input) const rawPeer = RawPeerConfig.parse(input) - return this.#updateEntry( + return this.updateEntry( 'peers', 'peer', id, resolved, rawPeer, - () => this.#connectorManager.addPeerConnector(resolved), - (oldId, newId) => this.#downloadsRepository?.reassignPeerId(oldId, newId), + () => this.connectorManager.addPeerConnector(resolved), + (oldId, newId) => this.downloadsRepository?.reassignPeerId(oldId, newId), ) } @@ -177,11 +177,11 @@ export class ConfigService { async addServer(input: unknown): Promise { const resolved = ServerConfig.parse(input) const rawServer = RawServerConfig.parse(input) - return this.#addEntry('servers', 'server', resolved, rawServer, () => this.#connectorManager.addServerConnector(resolved)) + return this.addEntry('servers', 'server', resolved, rawServer, () => this.connectorManager.addServerConnector(resolved)) } async removeServer(id: string): Promise { - return this.#removeEntry('servers', 'server', id) + return this.removeEntry('servers', 'server', id) } async updateServer(id: string, input: unknown): Promise { @@ -190,6 +190,6 @@ export class ConfigService { // is no download cascade (downloads key off peers, not servers) — so no onRekey. const resolved = ServerConfig.parse(input) const rawServer = RawServerConfig.parse(input) - return this.#updateEntry('servers', 'server', id, resolved, rawServer, () => this.#connectorManager.addServerConnector(resolved)) + return this.updateEntry('servers', 'server', id, resolved, rawServer, () => this.connectorManager.addServerConnector(resolved)) } } From f585c72d1c040abdbdffd2d14e157ca3fdafeafa Mon Sep 17 00:00:00 2001 From: Roz Date: Thu, 18 Jun 2026 12:21:59 +0200 Subject: [PATCH 22/22] fix: seed config service with default config when secret env vars are 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. --- apps/backend/src/lib/config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/lib/config.ts b/apps/backend/src/lib/config.ts index 5e7b028..69060ed 100644 --- a/apps/backend/src/lib/config.ts +++ b/apps/backend/src/lib/config.ts @@ -236,8 +236,13 @@ export async function getAppConfig({ APP_CONFIG_PATH }: Pick