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 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 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/apps/backend/src/__tests__/config-management.test.ts b/apps/backend/src/__tests__/config-management.test.ts new file mode 100644 index 0000000..295f3f6 --- /dev/null +++ b/apps/backend/src/__tests__/config-management.test.ts @@ -0,0 +1,351 @@ +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' +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, + 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) + }) + + 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( + 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' })), +) +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 +// 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 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, { downloadsRepository }) + return { app, mainApp, path, connectorManager, downloadsRepository, database } +} + +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' + 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) + }) +}) + +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) + }) +}) + +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 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() + 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__/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/__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/__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__/downloads-api.test.ts b/apps/backend/src/__tests__/downloads-api.test.ts index 329617b..7276faf 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 = { @@ -17,10 +17,12 @@ const envs: Envs = { LOG_LEVEL: 'fatal', OTEL_SERVICE_NAME: 'jack-server', PORT: 3000, + MANAGEMENT_PORT: 5226, NODE_ENV: 'test', } 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__/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/__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__/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..2651935 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: [], @@ -123,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/__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 f669e18..bbfa245 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' @@ -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') @@ -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__/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/__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: [], diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 0d72e25..0bfb4f4 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,36 @@ 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 = {}) { +// 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() { + 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 +87,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 +105,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/index.ts b/apps/backend/src/index.ts index 4d2db14..abc937b 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -5,8 +5,10 @@ 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 { 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' @@ -24,19 +26,25 @@ 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 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) +// 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 + ? new ConfigService({ path: envs.APP_CONFIG_PATH, raw: rawConfig, connectorManager, downloadsRepository }) + : undefined + 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 +53,34 @@ 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') +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 + } + + 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 @@ -63,7 +94,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 +152,7 @@ process.on('SIGINT', async () => { logger.info('SIGINT received, exiting') database.close() server.stop() + managementServer?.stop() await shutdownTelemetry() process.exit(0) }) @@ -129,6 +161,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/__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/__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/atomic-write.ts b/apps/backend/src/lib/atomic-write.ts new file mode 100644 index 0000000..382b0a1 --- /dev/null +++ b/apps/backend/src/lib/atomic-write.ts @@ -0,0 +1,27 @@ +import { chmod, rename, stat, unlink } from 'node:fs/promises' + +/** + * 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}.${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/config.ts b/apps/backend/src/lib/config.ts index 1abc0ff..69060ed 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]+$/ @@ -22,7 +23,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 +75,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 +107,28 @@ 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. +// 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(), @@ -123,6 +138,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,33 +158,17 @@ 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), }) 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 +177,33 @@ export const AppConfig = z.object({ export type AppConfig = z.infer -// 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. +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(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) +} + const DEFAULT_APP_CONFIG: z.input = { + version: MIGRATIONS.length, jack: { baseUrl: 'http://jack:5225', apiKey: { env: 'JACK_API_KEY' }, @@ -180,9 +212,8 @@ 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: [], peers: [], } @@ -194,7 +225,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) { @@ -203,18 +234,34 @@ export async function getAppConfig({ APP_CONFIG_PATH }: Pick logger.debug(`Validating app config`) - return AppConfig.parse(fileContent) + return { appConfig: AppConfig.parse(raw), raw } } 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/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/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/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/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/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/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..8466d90 100644 --- a/apps/backend/src/lib/servers/base.ts +++ b/apps/backend/src/lib/servers/base.ts @@ -3,13 +3,13 @@ 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 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: { url: string, name: string, apiKey: string, type: ConnectorType, headers?: ConnectorHeadersConfig }) { 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}` } @@ -103,7 +116,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 +127,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 +140,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 +154,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 +162,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,17 +209,17 @@ 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 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..cb3e15a 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,133 @@ 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) + + // 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) + } + + public async addPeerConnector(config: PeerConfig) { + const connector = new PeerConnector(config) + this._peerMap.set(connector.id, connector) + + 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) + + 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/lib/servers/peer.ts b/apps/backend/src/lib/servers/peer.ts index 4ac8c44..0cabc35 100644 --- a/apps/backend/src/lib/servers/peer.ts +++ b/apps/backend/src/lib/servers/peer.ts @@ -2,13 +2,14 @@ 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' 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,12 +116,12 @@ 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}`) }) } - @requireInitialization + @requiresInitialization async searchByImdbId(imdbId: string): Promise { return withSpan('peer.search_by_imdb', { 'peer.name': this.name, @@ -132,12 +133,12 @@ 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 }) } - @requireInitialization + @requiresInitialization async searchByTmdbId(tmdbId: string): Promise { return withSpan('peer.search_by_tmdb', { 'peer.name': this.name, @@ -146,25 +147,25 @@ 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 }) } /** 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, '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 }) } - @requireInitialization + @requiresInitialization async searchByTvdbId(tvdbId: string, season?: number, episode?: number): Promise { return withSpan('peer.search_by_tvdb', { 'peer.name': this.name, @@ -183,17 +184,17 @@ 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 }) } - @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, @@ -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/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 diff --git a/apps/backend/src/management-app.ts b/apps/backend/src/management-app.ts new file mode 100644 index 0000000..3c8a01c --- /dev/null +++ b/apps/backend/src/management-app.ts @@ -0,0 +1,29 @@ +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' +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'] } + configService?: ConfigService +}) { + 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, params.configService) + app.route('/config', getConfigRouter(configController)) + + app.onError(handleError(params.environment)) + + return app +} diff --git a/apps/backend/src/middleware/handle-error.ts b/apps/backend/src/middleware/handle-error.ts index 96ea64c..ff2a5e5 100644 --- a/apps/backend/src/middleware/handle-error.ts +++ b/apps/backend/src/middleware/handle-error.ts @@ -2,11 +2,17 @@ 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 { 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/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/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..a413924 --- /dev/null +++ b/apps/backend/src/modules/config/config.controller.ts @@ -0,0 +1,93 @@ +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 { + 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[] }, + private readonly configService?: ConfigService, + ) {} + + 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) } + } + + /** 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. + private async mutate(run: (service: ConfigService) => Promise) { + if (!this.configService) + throw new Error('Config mutations require a configured ConfigService') + try { + await run(this.configService) + } + catch (err) { + if (err instanceof z.ZodError) + throw new BadRequestError(z.prettifyError(err)) + throw err + } + return { ok: true } + } + + addPeer(input: unknown) { + return this.mutate(s => s.addPeer(input)) + } + + removePeer(id: string) { + return this.mutate(s => s.removePeer(id)) + } + + updatePeer(id: string, input: unknown) { + return this.mutate(s => s.updatePeer(id, input)) + } + + addServer(input: unknown) { + return this.mutate(s => s.addServer(input)) + } + + 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 new file mode 100644 index 0000000..0305978 --- /dev/null +++ b/apps/backend/src/modules/config/config.router.ts @@ -0,0 +1,45 @@ +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() + + app.get('/', c => c.json(controller.listConfig())) + app.get('/peers', c => c.json(controller.listPeers())) + app.get('/servers', c => c.json(controller.listServers())) + + // 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', zValidator('json', RawPeerConfig), async (c) => { + return c.json(await controller.addPeer(c.req.valid('json')), 201) + }) + + app.delete('/peers/:id', zValidator('param', idParam), async (c) => { + return c.json(await controller.removePeer(c.req.valid('param').id)) + }) + + 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', zValidator('json', RawServerConfig), async (c) => { + return c.json(await controller.addServer(c.req.valid('json')), 201) + }) + + app.delete('/servers/:id', zValidator('param', idParam), async (c) => { + return c.json(await controller.removeServer(c.req.valid('param').id)) + }) + + 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'))) + }) + } + + 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..6836d0a --- /dev/null +++ b/apps/backend/src/modules/config/config.service.ts @@ -0,0 +1,195 @@ +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, 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 +// 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 { + 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. + 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 + } + + /** + * 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 + return new ConfigService({ path: params.path, raw, connectorManager: params.connectorManager, downloadsRepository: params.downloadsRepository }) + } + + 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(() => {}, () => {}) + 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. + private async persist(next: RawConfig): Promise { + await atomicWriteFile(this.path, jsonc.stringify(next, { space: 2 })) + } + + private slice(slice: Slice): RawEntry[] { + return (this.raw[slice] ?? []) as RawEntry[] + } + + 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 + // the live connector map. `addConnector` instantiates the right connector type; + // `onRekey` lets peers cascade their download rows on a URL change (servers pass none). + + 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 + await addConnector() + }) + } + + 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) + }) + } + + private 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 entries = this.slice(slice) + const index = this.indexById(entries, id) + if (index === -1) + throw new NotFoundError(`No ${label} found with id "${id}"`) + + // 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 + + // 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) + } + }) + } + + // ── 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 removePeer(id: string): Promise { + 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( + 'peers', + 'peer', + id, + resolved, + rawPeer, + () => this.connectorManager.addPeerConnector(resolved), + (oldId, newId) => this.downloadsRepository?.reassignPeerId(oldId, newId), + ) + } + + // ── 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.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) + return this.updateEntry('servers', 'server', id, resolved, rawServer, () => this.connectorManager.addServerConnector(resolved)) + } +} 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) 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), 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..94e9428 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' @@ -45,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 @@ -53,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 { @@ -63,13 +64,13 @@ 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 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/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 29c20ec..4dbf106 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' @@ -49,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, ) {} @@ -59,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) { - span.setAttribute('release.count', 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, @@ -77,7 +79,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 +91,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/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/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.', + }, + ], + }, + }, +) 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) { 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