This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@devrunbook/config",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint src --max-warnings=0",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseEnvironment, tryParseEnvironment } from './index'
|
||||
|
||||
const valid = {
|
||||
DATABASE_URL: 'postgresql://user:password@localhost:5432/devrunbook',
|
||||
PUBLIC_BASE_URL: 'http://localhost:3000',
|
||||
SESSION_SECRET: '01234567890123456789012345678901',
|
||||
INTEGRATION_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64'),
|
||||
INTEGRATION_ENCRYPTION_KEY_VERSION: 'v1',
|
||||
CONTENT_ROOT: '/content',
|
||||
ARTIFACT_ROOT: '/artifacts',
|
||||
}
|
||||
|
||||
describe('parseEnvironment', () => {
|
||||
it('applies documented safe defaults', () => {
|
||||
const parsed = parseEnvironment(valid)
|
||||
expect(parsed.REGISTRATION_MODE).toBe('closed')
|
||||
expect(parsed.MAX_ARCHIVE_FILES).toBe(500)
|
||||
expect(parsed.MAINTENANCE_MODE).toBe(false)
|
||||
expect(parsed.GITEA_REQUEST_TIMEOUT_MS).toBe(15_000)
|
||||
expect(parsed.GITEA_MAX_REDIRECTS).toBe(3)
|
||||
expect(parsed.GITEA_MAX_FILE_BYTES).toBe(1_048_576)
|
||||
expect(parsed.GITEA_MAX_FILES_PER_SNAPSHOT).toBe(200)
|
||||
expect(parsed.INTEGRATION_ENCRYPTION_OLD_KEYS).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects short secrets', () => {
|
||||
const result = tryParseEnvironment({ ...valid, SESSION_SECRET: 'short' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('parses a bounded old integration key ring and rejects malformed keys', () => {
|
||||
const oldKey = Buffer.alloc(32, 9).toString('base64')
|
||||
expect(
|
||||
parseEnvironment({
|
||||
...valid,
|
||||
INTEGRATION_ENCRYPTION_OLD_KEYS: JSON.stringify({ legacy: oldKey }),
|
||||
}).INTEGRATION_ENCRYPTION_OLD_KEYS,
|
||||
).toEqual({ legacy: oldKey })
|
||||
expect(
|
||||
tryParseEnvironment({
|
||||
...valid,
|
||||
INTEGRATION_ENCRYPTION_OLD_KEYS: '{"legacy":"short"}',
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const booleanString = z
|
||||
.enum(['true', 'false'])
|
||||
.transform((value) => value === 'true')
|
||||
|
||||
const integerString = (minimum: number, maximum: number) =>
|
||||
z.coerce.number().int().min(minimum).max(maximum)
|
||||
|
||||
const integrationOldKeys = z
|
||||
.string()
|
||||
.default('{}')
|
||||
.transform((value, context): Readonly<Record<string, string>> => {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'must be a JSON object of key versions to base64 keys',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'must be a JSON object of key versions to base64 keys',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
const result: Record<string, string> = {}
|
||||
for (const [version, key] of Object.entries(parsed)) {
|
||||
if (
|
||||
version.length === 0 ||
|
||||
version.length > 64 ||
|
||||
typeof key !== 'string' ||
|
||||
Buffer.from(key, 'base64').byteLength !== 32
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message:
|
||||
'each old key must have a 1-64 character version and a base64-encoded 32-byte value',
|
||||
})
|
||||
return z.NEVER
|
||||
}
|
||||
result[version] = key
|
||||
}
|
||||
return Object.freeze(result)
|
||||
})
|
||||
|
||||
const environmentSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1),
|
||||
PUBLIC_BASE_URL: z.url(),
|
||||
SESSION_SECRET: z.string().min(32),
|
||||
INTEGRATION_ENCRYPTION_KEY: z
|
||||
.string()
|
||||
.refine((value) => Buffer.from(value, 'base64').byteLength === 32, {
|
||||
message: 'must be a base64-encoded 32-byte key',
|
||||
}),
|
||||
INTEGRATION_ENCRYPTION_KEY_VERSION: z.string().min(1).max(64),
|
||||
INTEGRATION_ENCRYPTION_OLD_KEYS: integrationOldKeys,
|
||||
CONTENT_ROOT: z.string().min(1),
|
||||
ARTIFACT_ROOT: z.string().min(1),
|
||||
BOOTSTRAP_TOKEN: z.string().min(16).optional(),
|
||||
REGISTRATION_MODE: z.enum(['closed', 'invite']).default('closed'),
|
||||
TRUSTED_PROXY_CIDRS: z.string().default(''),
|
||||
MAINTENANCE_MODE: booleanString.default(false),
|
||||
MAX_IMPORT_BYTES: integerString(1, 52_428_800).default(10_485_760),
|
||||
MAX_EXPANDED_ARCHIVE_BYTES: integerString(1, 262_144_000).default(52_428_800),
|
||||
MAX_ARCHIVE_FILES: integerString(1, 5_000).default(500),
|
||||
MAX_SINGLE_FILE_BYTES: integerString(1, 26_214_400).default(5_242_880),
|
||||
MAX_PROMPT_BYTES: integerString(1, 10_485_760).default(2_097_152),
|
||||
MAX_EVIDENCE_BYTES: integerString(1, 2_097_152).default(262_144),
|
||||
MAX_ARTIFACT_BYTES: integerString(1, 52_428_800).default(5_242_880),
|
||||
GITEA_PRIVATE_NETWORK_POLICY: z
|
||||
.enum(['deny', 'allow-explicit-hosts'])
|
||||
.default('deny'),
|
||||
GITEA_ALLOWED_HOSTS: z.string().default(''),
|
||||
GITEA_REQUEST_TIMEOUT_MS: integerString(1_000, 60_000).default(15_000),
|
||||
GITEA_MAX_REDIRECTS: integerString(0, 10).default(3),
|
||||
GITEA_MAX_FILE_BYTES: integerString(1, 5_242_880).default(1_048_576),
|
||||
GITEA_MAX_FILES_PER_SNAPSHOT: integerString(1, 500).default(200),
|
||||
ARTIFACT_RETENTION_DAYS: integerString(1, 3_650).default(90),
|
||||
AUDIT_RETENTION_DAYS: integerString(1, 3_650).default(180),
|
||||
LOG_RETENTION_DAYS: integerString(1, 365).default(30),
|
||||
SNAPSHOT_RETENTION_COUNT: integerString(1, 1_000).default(20),
|
||||
LOG_LEVEL: z
|
||||
.enum(['trace', 'debug', 'info', 'warn', 'error'])
|
||||
.default('info'),
|
||||
WORKER_POLL_INTERVAL_MS: integerString(100, 60_000).default(2_000),
|
||||
JOB_LEASE_SECONDS: integerString(10, 3_600).default(60),
|
||||
REPOSITORY_REFRESH_SCHEDULE_MS: integerString(60_000, 86_400_000).default(
|
||||
300_000,
|
||||
),
|
||||
REPOSITORY_STALE_AFTER_HOURS: integerString(1, 720).default(24),
|
||||
})
|
||||
|
||||
export type AppConfig = z.infer<typeof environmentSchema>
|
||||
|
||||
export function parseEnvironment(
|
||||
environment: Record<string, string | undefined>,
|
||||
): AppConfig {
|
||||
return environmentSchema.parse(environment)
|
||||
}
|
||||
|
||||
export function tryParseEnvironment(
|
||||
environment: Record<string, string | undefined>,
|
||||
) {
|
||||
return environmentSchema.safeParse(environment)
|
||||
}
|
||||
|
||||
export const redactedEnvironmentKeys = [
|
||||
'DATABASE_URL',
|
||||
'SESSION_SECRET',
|
||||
'INTEGRATION_ENCRYPTION_KEY',
|
||||
'INTEGRATION_ENCRYPTION_OLD_KEYS',
|
||||
'BOOTSTRAP_TOKEN',
|
||||
] as const
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user