Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a71c2ff
feat: add versioned config migrations
roziscoding Jun 10, 2026
a8ba400
chore: bump typescript to 6 and enable noUnusedLocals/Parameters
roziscoding Jun 10, 2026
1fcde96
docs: note ai_docs is gitignored in AGENTS.md
roziscoding Jun 10, 2026
04a9253
feat: redact sensitive fields in logs
roziscoding Jun 10, 2026
0f37496
feat: route span attributes through a redacting funnel
roziscoding Jun 10, 2026
93e2948
test: set required config version in app fixtures
roziscoding Jun 10, 2026
80c598a
style: use single quotes for release tag glob
roziscoding Jun 11, 2026
6fcd297
feat: add management API on a separate port with read endpoints
roziscoding Jun 14, 2026
9954a93
feat: add live addPeer via management API
roziscoding Jun 14, 2026
9dd1f8a
feat: live removePeer + same-URL updatePeer via management API
roziscoding Jun 14, 2026
e151ac2
feat: peer URL-change rekey + download peer_id cascade
roziscoding Jun 14, 2026
84a0f7b
feat: live servers CRUD via management API
roziscoding Jun 14, 2026
df2b1f0
feat: config migration write-back with .bak; fix up-to-date-file boot…
roziscoding Jun 14, 2026
9dc4707
feat: live connector visibility for search fan-out
roziscoding Jun 14, 2026
0fdc75b
refactor: dedupe config CRUD, gate mutation routes, harden atomic write
roziscoding Jun 14, 2026
1f1b143
refactor: complete connector-lifecycle refactor; green the branch
roziscoding Jun 14, 2026
3060c85
fix: scope version catch so config survives a downgrade
roziscoding Jun 17, 2026
51ab3e6
fix: log management-port collision at error level, not fatal
roziscoding Jun 17, 2026
10cb7e7
feat: seed request headers from JACK_HEADERS env in cli
roziscoding Jun 17, 2026
a722fc4
refactor: validate config mutation bodies with zod; flatten managemen…
roziscoding Jun 18, 2026
b503d97
refactor: use private keyword instead of JS native private fields
roziscoding Jun 18, 2026
f585c72
fix: seed config service with default config when secret env vars are…
roziscoding Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ name: Create Release
on:
push:
tags:
- "v*"
- 'v*'

permissions:
contents: write
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"module": "index.ts",
"peerDependencies": {
"typescript": "^5"
"typescript": "^6.0.3"
},
"dependencies": {
"@hono/otel": "1.1.2",
Expand Down
351 changes: 351 additions & 0 deletions apps/backend/src/__tests__/config-management.test.ts

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions apps/backend/src/__tests__/config-migration.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
232 changes: 43 additions & 189 deletions apps/backend/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
})
Expand All @@ -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<typeof _secret> = 'literal'
const _in2: z.input<typeof _secret> = { env: 'X' }
const _in3: z.input<typeof _secret> = { file: '/run/secrets/x' }
const _out: z.output<typeof _secret> = '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<string, unknown>
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(
<T extends object>(obj: T) => ({ ...obj, version: 1, m1: true }),
<T extends object>(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<string, unknown>
expect(result).toMatchObject({ version: 2, kept: true, m2: true })
expect(result.m1).toBeUndefined()
}
finally {
MIGRATIONS.length = 0
MIGRATIONS.push(...original)
}
})
})
10 changes: 5 additions & 5 deletions apps/backend/src/__tests__/connector-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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({})

Expand All @@ -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({})

Expand All @@ -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)
Expand Down
Loading
Loading