diff --git a/apps/api/src/index-authorization.ts b/apps/api/src/index-authorization.ts new file mode 100644 index 0000000..47c1762 --- /dev/null +++ b/apps/api/src/index-authorization.ts @@ -0,0 +1,320 @@ +import { randomUUID } from "node:crypto"; +import type { Context, MiddlewareHandler } from "hono"; + +import { + type AuditEvent, + type Permission, + permissionRequiresCapability, + type RoomCapability, +} from "@rakkr/shared"; + +import type { createAuditStore } from "./audit-store.js"; +import type { AuthResult, LocalAuthService } from "./auth-service.js"; +import type { AppBindings, AuditTarget, RecordAuditEvent } from "./http-types.js"; +import type { createResourceScopeTargets } from "./resource-scope-targets.js"; +import type { createRoomRosterStore } from "./room-roster-store.js"; + +export function requestContext(c: Context, sessionId?: string) { + return { + ipAddress: c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip"), + sessionId: sessionId ?? c.req.header("x-rakkr-session-id"), + userAgent: c.req.header("user-agent"), + }; +} + +export function currentAuth(c: Context) { + return c.get("auth"); +} + +export function currentUser(c: Context) { + const user = currentAuth(c).user; + + if (!user) { + throw new Error("Authenticated route reached without a user"); + } + + return user; +} + +interface AuthorizationDependencies { + auditStore: ReturnType; + authService: LocalAuthService; + resourceScopeTargets: ReturnType; + roomRosterStore: ReturnType; +} + +// The audit + authorization closures shared by every route. Extracted from the +// composition root into a factory over the stores it needs so index.ts stays a +// thin wiring file; behavior and the role-scope OR room-capability decision are +// preserved exactly. +export function createAuthorization({ + auditStore, + authService, + resourceScopeTargets, + roomRosterStore, +}: AuthorizationDependencies) { + const recordAuditEvent: RecordAuditEvent = async (c, input) => { + const actor = + input.actor ?? + (input.auth?.user + ? { + id: input.auth.user.id, + name: input.auth.user.name, + roles: input.auth.user.roles, + type: "user" as const, + } + : { + id: "anonymous", + name: "Anonymous", + roles: [], + type: "user" as const, + }); + const event: AuditEvent = { + action: input.action, + actor, + actorContext: requestContext(c, input.auth?.sessionId), + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: { + method: c.req.method, + path: c.req.path, + ...input.details, + }, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; + + function requirePermission( + permission: Permission, + action: string, + target: (c: Context) => AuditTarget | Promise = () => ({ + type: "controller", + }), + ): MiddlewareHandler { + return async (c, next) => { + const auth = await authService.authenticate(c.req.header("authorization")); + const auditTarget = await target(c); + const decision = await permissionDecision(auth.user, permission, auditTarget); + const reason = decision.allowed + ? undefined + : authorizationReason({ + authenticated: Boolean(auth.user), + hasPermission: decision.hasPermission, + hasScope: decision.scope.allowed, + scopeReason: decision.scope.reason, + }); + + await recordAuditEvent(c, { + action, + auth, + details: { + grantedViaRoomCapability: decision.grantedViaRoomCapability, + requiredPermission: permission, + resourceScope: auditTarget, + resourceScopeDecision: decision.scope.reason, + roomCapability: decision.requiredCapability, + roles: auth.user?.roles ?? [], + }, + outcome: decision.allowed ? "allowed" : "denied", + permission, + reason, + target: auditTarget, + }); + + if (!decision.allowed) { + return c.json( + { + error: auth.user ? "Forbidden" : "Unauthorized", + permission, + }, + auth.user ? 403 : 401, + ); + } + + c.set("auth", auth); + await next(); + }; + } + + // The role-scope OR room-capability allow decision shared by requirePermission and + // the fine-grained per-room checks in recording/schedule handlers. A room roster + // (a manual grant or a calendar meeting-assignment) grants a per-action capability + // over its room even without the role permission — but never past an explicit deny. + // Exported for test coverage of the access-policy-DENY-beats-roster-grant + // precedence (the `scope.reason !== "access_policy_denied"` guard below). + async function permissionDecision( + user: AuthResult["user"], + permission: Permission, + target: AuditTarget, + ) { + const hasPermission = user?.permissions.includes(permission) ?? false; + const scope = user + ? await resourceScopeDecision(user, target) + : { allowed: false, reason: "unauthenticated" as const }; + let allowed = hasPermission && scope.allowed; + let grantedViaRoomCapability = false; + const requiredCapability = permissionRequiresCapability(permission); + + if (!allowed && user && scope.reason !== "access_policy_denied" && requiredCapability) { + grantedViaRoomCapability = await roomCapabilityAuthorizes(user, requiredCapability, target); + allowed = grantedViaRoomCapability; + } + + return { allowed, grantedViaRoomCapability, hasPermission, requiredCapability, scope }; + } + + // Whether a user is authorized for a permission against a target, reusing the same + // logic as requirePermission without auditing or an HTTP response. Used for the + // fine-grained per-room check once a request's room is resolved from its channels. + async function authorizeTargetForUser( + user: AuthResult["user"], + permission: Permission, + target: AuditTarget, + ) { + return (await permissionDecision(user, permission, target)).allowed; + } + + function authorizationReason(input: { + authenticated: boolean; + hasPermission: boolean; + hasScope: boolean; + scopeReason?: string; + }) { + if (!input.authenticated) { + return "unauthenticated"; + } + + if (!input.hasPermission) { + return "missing_permission"; + } + + if (!input.hasScope) { + return input.scopeReason ?? "missing_resource_scope"; + } + + return undefined; + } + + async function hasResourceScope(user: AuthResult["user"], target: AuditTarget) { + return (await resourceScopeDecision(user, target)).allowed; + } + + async function resourceScopeDecision(user: AuthResult["user"], target: AuditTarget) { + if (!user || !target.id) { + return { + allowed: Boolean(user), + reason: user ? undefined : "unauthenticated", + }; + } + + const targets = await resourceScopeTargets(target); + const policyDecision = await authService.accessPolicyDecision(user, targets); + + if (policyDecision?.effect === "deny") { + return { + allowed: false, + reason: "access_policy_denied", + }; + } + + if (user.roles.includes("owner") || user.roles.includes("admin")) { + return { + allowed: true, + reason: undefined, + }; + } + + if (policyDecision?.effect === "allow") { + return { + allowed: true, + reason: undefined, + }; + } + + const allowedByGrant = targets.some((candidate) => + user.resourceGrants.some( + (grant) => + (grant.resourceType === candidate.type || grant.resourceType === "*") && + (grant.resourceId === candidate.id || grant.resourceId === "*"), + ), + ); + + return { + allowed: allowedByGrant, + reason: allowedByGrant ? undefined : "missing_resource_scope", + }; + } + + function rosterSubject(user: NonNullable) { + return { groupIds: user.groups.map((group) => group.id), userId: user.id }; + } + + // Room ids a target resolves into (via the shared hierarchy expansion). + function roomIdsFromTargets(targets: AuditTarget[]) { + const roomIds: string[] = []; + + for (const target of targets) { + if (target.type === "room" && target.id) { + roomIds.push(target.id); + } + } + + return roomIds; + } + + // The set of room ids the user holds any capability in (direct or via a group). + async function rosterRoomIds(user: NonNullable) { + return new Set((await roomRosterStore.roomsForSubject(rosterSubject(user))).keys()); + } + + // True when the user holds `capability` in a room that `target` resolves into, + // via the room roster (a manual grant or a calendar meeting-assignment). Reuses + // the same hierarchy expansion as role scoping, so a room grant covers that + // room's nodes, interfaces, channels, schedules, and recordings. + async function roomCapabilityAuthorizes( + user: NonNullable, + capability: RoomCapability, + target: AuditTarget, + ) { + if (!target.id) { + return false; + } + + const roomIds = roomIdsFromTargets(await resourceScopeTargets(target)); + + if (roomIds.length === 0) { + return false; + } + + const subject = rosterSubject(user); + + for (const roomId of roomIds) { + const capabilities = await roomRosterStore.effectiveCapabilities(subject, roomId); + + if (capabilities.has(capability)) { + return true; + } + } + + return false; + } + + return { + authorizeTargetForUser, + hasResourceScope, + permissionDecision, + recordAuditEvent, + requirePermission, + rosterRoomIds, + }; +} diff --git a/apps/api/src/index-readiness.ts b/apps/api/src/index-readiness.ts new file mode 100644 index 0000000..7c2651e --- /dev/null +++ b/apps/api/src/index-readiness.ts @@ -0,0 +1,42 @@ +import { createDatabase, sql } from "@rakkr/db"; + +const READINESS_DB_TIMEOUT_MS = 3000; + +// Readiness backing for /readyz. When DATABASE_URL is set we hold ONE dedicated +// health client and probe it with a bounded `select 1`; a hung DB must not hang +// the probe, so the query races a short timeout. When DATABASE_URL is unset +// (memory-store / test mode) there is no DB to depend on, so the controller is +// always ready. +export function createReadinessProbe(databaseUrl: string | undefined) { + const readinessDatabase = databaseUrl ? createDatabase(databaseUrl) : undefined; + + async function checkDatabaseReady(): Promise { + if (!readinessDatabase) { + return true; + } + + let timer: ReturnType | undefined; + + try { + const probe = readinessDatabase.execute(sql`select 1`); + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error("database readiness probe timed out")), + READINESS_DB_TIMEOUT_MS, + ); + }); + + await Promise.race([probe, timeout]); + + return true; + } catch { + return false; + } finally { + if (timer) { + clearTimeout(timer); + } + } + } + + return { checkDatabaseReady }; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 33d4c51..298d146 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,35 +1,31 @@ import { serve } from "@hono/node-server"; -import { createDatabase, sql } from "@rakkr/db"; import "dotenv/config"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; -import { randomUUID } from "node:crypto"; import { z } from "zod"; -import type { Context, MiddlewareHandler } from "hono"; import { registerAgentMonitorRoutes } from "./agent-monitor-routes.js"; import { registerAgentRoutes } from "./agent-routes.js"; import { agentReleaseService } from "./agent-release-service.js"; import { createApiRunners, startApiRunners } from "./api-runners.js"; -import { - type AuditEvent, - defaultCalendarGrantCapabilities, - type Permission, - permissionRequiresCapability, - type RoomCapability, - type ScheduleSummary, -} from "@rakkr/shared"; +import { defaultCalendarGrantCapabilities, type ScheduleSummary } from "@rakkr/shared"; import { registerAuditRoutes } from "./audit-routes.js"; import { createAuditStore } from "./audit-store.js"; import { registerAuthLifecycleRoutes } from "./auth-lifecycle-routes.js"; import { registerAuthManagementRoutes } from "./auth-management-routes.js"; import { clearOidcLoginStateCookie, registerAuthOidcRoutes } from "./auth-oidc-routes.js"; -import { AuthError, LocalAuthService, type AuthResult } from "./auth-service.js"; +import { AuthError, LocalAuthService } from "./auth-service.js"; import { registerHealthRoutes } from "./health-routes.js"; import { createHealthEventStore } from "./health-store.js"; -import type { RecordAuditEvent } from "./http-types.js"; +import { + createAuthorization, + currentAuth, + currentUser, + requestContext, +} from "./index-authorization.js"; +import { createReadinessProbe } from "./index-readiness.js"; import { nodes as seedNodes, recordings, schedules as seedSchedules } from "./demo-data.js"; -import type { AppBindings, AuditTarget } from "./http-types.js"; +import type { AppBindings } from "./http-types.js"; import { createListenMonitorStore } from "./listen-monitor-store.js"; import { createListenSessionStore } from "./listen-session-store.js"; import { createMeterFrameStore } from "./meter-store.js"; @@ -68,42 +64,7 @@ const startedAt = new Date(); const port = Number(process.env.PORT ?? 8787); const webOrigin = process.env.RAKKR_WEB_ORIGIN ?? "http://localhost:5173"; -// Readiness backing for /readyz. When DATABASE_URL is set we hold ONE dedicated -// health client and probe it with a bounded `select 1`; a hung DB must not hang -// the probe, so the query races a short timeout. When DATABASE_URL is unset -// (memory-store / test mode) there is no DB to depend on, so the controller is -// always ready. -const readinessDatabaseUrl = process.env.DATABASE_URL; -const readinessDatabase = readinessDatabaseUrl ? createDatabase(readinessDatabaseUrl) : undefined; -const READINESS_DB_TIMEOUT_MS = 3000; - -async function checkDatabaseReady(): Promise { - if (!readinessDatabase) { - return true; - } - - let timer: ReturnType | undefined; - - try { - const probe = readinessDatabase.execute(sql`select 1`); - const timeout = new Promise((_resolve, reject) => { - timer = setTimeout( - () => reject(new Error("database readiness probe timed out")), - READINESS_DB_TIMEOUT_MS, - ); - }); - - await Promise.race([probe, timeout]); - - return true; - } catch { - return false; - } finally { - if (timer) { - clearTimeout(timer); - } - } -} +const { checkDatabaseReady } = createReadinessProbe(process.env.DATABASE_URL); const auditStore = createAuditStore(); const authService = new LocalAuthService(); @@ -169,216 +130,6 @@ const loginRequestSchema = z.object({ password: z.string().min(1), }); -function requestContext(c: Context, sessionId?: string) { - return { - ipAddress: c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip"), - sessionId: sessionId ?? c.req.header("x-rakkr-session-id"), - userAgent: c.req.header("user-agent"), - }; -} - -const recordAuditEvent: RecordAuditEvent = async (c, input) => { - const actor = - input.actor ?? - (input.auth?.user - ? { - id: input.auth.user.id, - name: input.auth.user.name, - roles: input.auth.user.roles, - type: "user" as const, - } - : { - id: "anonymous", - name: "Anonymous", - roles: [], - type: "user" as const, - }); - const event: AuditEvent = { - action: input.action, - actor, - actorContext: requestContext(c, input.auth?.sessionId), - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: { - method: c.req.method, - path: c.req.path, - ...input.details, - }, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; -}; - -function requirePermission( - permission: Permission, - action: string, - target: (c: Context) => AuditTarget | Promise = () => ({ - type: "controller", - }), -): MiddlewareHandler { - return async (c, next) => { - const auth = await authService.authenticate(c.req.header("authorization")); - const auditTarget = await target(c); - const decision = await permissionDecision(auth.user, permission, auditTarget); - const reason = decision.allowed - ? undefined - : authorizationReason({ - authenticated: Boolean(auth.user), - hasPermission: decision.hasPermission, - hasScope: decision.scope.allowed, - scopeReason: decision.scope.reason, - }); - - await recordAuditEvent(c, { - action, - auth, - details: { - grantedViaRoomCapability: decision.grantedViaRoomCapability, - requiredPermission: permission, - resourceScope: auditTarget, - resourceScopeDecision: decision.scope.reason, - roomCapability: decision.requiredCapability, - roles: auth.user?.roles ?? [], - }, - outcome: decision.allowed ? "allowed" : "denied", - permission, - reason, - target: auditTarget, - }); - - if (!decision.allowed) { - return c.json( - { - error: auth.user ? "Forbidden" : "Unauthorized", - permission, - }, - auth.user ? 403 : 401, - ); - } - - c.set("auth", auth); - await next(); - }; -} - -// The role-scope OR room-capability allow decision shared by requirePermission and -// the fine-grained per-room checks in recording/schedule handlers. A room roster -// (a manual grant or a calendar meeting-assignment) grants a per-action capability -// over its room even without the role permission — but never past an explicit deny. -// Exported for test coverage of the access-policy-DENY-beats-roster-grant -// precedence (the `scope.reason !== "access_policy_denied"` guard below). -export async function permissionDecision( - user: AuthResult["user"], - permission: Permission, - target: AuditTarget, -) { - const hasPermission = user?.permissions.includes(permission) ?? false; - const scope = user - ? await resourceScopeDecision(user, target) - : { allowed: false, reason: "unauthenticated" as const }; - let allowed = hasPermission && scope.allowed; - let grantedViaRoomCapability = false; - const requiredCapability = permissionRequiresCapability(permission); - - if (!allowed && user && scope.reason !== "access_policy_denied" && requiredCapability) { - grantedViaRoomCapability = await roomCapabilityAuthorizes(user, requiredCapability, target); - allowed = grantedViaRoomCapability; - } - - return { allowed, grantedViaRoomCapability, hasPermission, requiredCapability, scope }; -} - -// Whether a user is authorized for a permission against a target, reusing the same -// logic as requirePermission without auditing or an HTTP response. Used for the -// fine-grained per-room check once a request's room is resolved from its channels. -async function authorizeTargetForUser( - user: AuthResult["user"], - permission: Permission, - target: AuditTarget, -) { - return (await permissionDecision(user, permission, target)).allowed; -} - -function authorizationReason(input: { - authenticated: boolean; - hasPermission: boolean; - hasScope: boolean; - scopeReason?: string; -}) { - if (!input.authenticated) { - return "unauthenticated"; - } - - if (!input.hasPermission) { - return "missing_permission"; - } - - if (!input.hasScope) { - return input.scopeReason ?? "missing_resource_scope"; - } - - return undefined; -} - -async function hasResourceScope(user: AuthResult["user"], target: AuditTarget) { - return (await resourceScopeDecision(user, target)).allowed; -} - -async function resourceScopeDecision(user: AuthResult["user"], target: AuditTarget) { - if (!user || !target.id) { - return { - allowed: Boolean(user), - reason: user ? undefined : "unauthenticated", - }; - } - - const targets = await resourceScopeTargets(target); - const policyDecision = await authService.accessPolicyDecision(user, targets); - - if (policyDecision?.effect === "deny") { - return { - allowed: false, - reason: "access_policy_denied", - }; - } - - if (user.roles.includes("owner") || user.roles.includes("admin")) { - return { - allowed: true, - reason: undefined, - }; - } - - if (policyDecision?.effect === "allow") { - return { - allowed: true, - reason: undefined, - }; - } - - const allowedByGrant = targets.some((candidate) => - user.resourceGrants.some( - (grant) => - (grant.resourceType === candidate.type || grant.resourceType === "*") && - (grant.resourceId === candidate.id || grant.resourceId === "*"), - ), - ); - - return { - allowed: allowedByGrant, - reason: allowedByGrant ? undefined : "missing_resource_scope", - }; -} - const resourceScopeTargets = createResourceScopeTargets({ healthEventStore, nodeStore, @@ -386,78 +137,27 @@ const resourceScopeTargets = createResourceScopeTargets({ scheduleStore, }); -function rosterSubject(user: NonNullable) { - return { groupIds: user.groups.map((group) => group.id), userId: user.id }; -} - -// Room ids a target resolves into (via the shared hierarchy expansion). -function roomIdsFromTargets(targets: AuditTarget[]) { - const roomIds: string[] = []; - - for (const target of targets) { - if (target.type === "room" && target.id) { - roomIds.push(target.id); - } - } - - return roomIds; -} - -// The set of room ids the user holds any capability in (direct or via a group). -async function rosterRoomIds(user: NonNullable) { - return new Set((await roomRosterStore.roomsForSubject(rosterSubject(user))).keys()); -} - -// True when the user holds `capability` in a room that `target` resolves into, -// via the room roster (a manual grant or a calendar meeting-assignment). Reuses -// the same hierarchy expansion as role scoping, so a room grant covers that -// room's nodes, interfaces, channels, schedules, and recordings. -async function roomCapabilityAuthorizes( - user: NonNullable, - capability: RoomCapability, - target: AuditTarget, -) { - if (!target.id) { - return false; - } - - const roomIds = roomIdsFromTargets(await resourceScopeTargets(target)); - - if (roomIds.length === 0) { - return false; - } - - const subject = rosterSubject(user); - - for (const roomId of roomIds) { - const capabilities = await roomRosterStore.effectiveCapabilities(subject, roomId); - - if (capabilities.has(capability)) { - return true; - } - } - - return false; -} - -function currentAuth(c: Context) { - return c.get("auth"); -} - -function currentUser(c: Context) { - const user = currentAuth(c).user; - - if (!user) { - throw new Error("Authenticated route reached without a user"); - } - - return user; -} +const authorization = createAuthorization({ + auditStore, + authService, + resourceScopeTargets, + roomRosterStore, +}); +// Re-exported for test coverage of the access-policy-DENY-beats-roster-grant +// precedence; see createAuthorization in index-authorization.ts. +export const permissionDecision = authorization.permissionDecision; +const { + authorizeTargetForUser, + hasResourceScope, + recordAuditEvent, + requirePermission, + rosterRoomIds, +} = authorization; // Per-user resource visibility + meter/monitor access decisions. Extracted to a // sibling factory to keep this composition root within the LOC budget; the -// factory only builds closures over the roster/scope helpers above, so wiring it -// here preserves behavior and the startup order of side effects exactly. +// factory only builds closures over the authorization + roster/scope helpers, so +// wiring it here preserves behavior and the startup order of side effects exactly. const { canServeWholeNodeMonitor, filterMeterFrameForUser, diff --git a/apps/api/test/agent-routes-harness.ts b/apps/api/test/agent-routes-harness.ts new file mode 100644 index 0000000..d7b7756 --- /dev/null +++ b/apps/api/test/agent-routes-harness.ts @@ -0,0 +1,345 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { + AuditEvent, + CurrentUser, + MeterFrame, + Permission, + RecorderNode, + RecordingProfile, + RecordingSummary, +} from "@rakkr/shared"; +import type { AuthResult } from "../src/auth-service.js"; +import type { RecordAuditEvent, RequirePermission } from "../src/http-types.js"; +import type { MeterFrameStore } from "../src/meter-store.js"; +import type { NodeHeartbeatInput, NodeStore } from "../src/node-store.js"; +import type { RecordingStore } from "../src/recording-store.js"; +import type { SettingsStore } from "../src/settings-store.js"; + +// Shared harness for the agent route tests. Extracted from agent-routes.test.ts +// to keep each test file under the 1000-LOC guard. +// +// The store modules read their RAKKR_*_STORE_PATH env vars at import time, so +// the env setup below MUST run before the dynamic imports. + +const agentRoot = await mkdtemp(path.join(tmpdir(), "rakkr-agent-routes-")); +process.env.DATABASE_URL = ""; +process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(agentRoot, "jobs.json"); +process.env.RAKKR_RECORDING_CACHE_DIR = path.join(agentRoot, "cache"); +process.env.RAKKR_RETENTION_POLICY_STORE_PATH = path.join(agentRoot, "retention-policies.json"); +process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(agentRoot, "upload-policies.json"); +process.env.RAKKR_UPLOAD_QUEUE_STORE_PATH = path.join(agentRoot, "upload-queue.json"); + +const { createAuditStore } = await import("../src/audit-store.js"); +const { registerAgentRoutes } = await import("../src/agent-routes.js"); +const { createHealthEventStore } = await import("../src/health-store.js"); +const { createRecordingJob } = await import("../src/recording-jobs.js"); +const { createRetentionPolicy } = await import("../src/retention-policies.js"); +const { registerRecordingRoutes } = await import("../src/recording-routes.js"); +const { createUploadPolicy } = await import("../src/upload-policies.js"); + +export { + createAuditStore, + createHealthEventStore, + createRecordingJob, + createRetentionPolicy, + createUploadPolicy, + registerAgentRoutes, + registerRecordingRoutes, +}; + +test.after(async () => { + await rm(agentRoot, { force: true, recursive: true }); +}); + +export function memoryNodeStore(nodes: RecorderNode[] = [node()]): NodeStore { + return { + async authenticateCredential(token) { + return token === "node-token" + ? { + credentialId: "cred_agent_test", + nodeId: nodes[0]?.id ?? "node_agent_test", + tokenPrefix: "node-token", + } + : undefined; + }, + async enroll() { + throw new Error("not implemented"); + }, + async find(nodeId) { + return nodes.find((candidate) => candidate.id === nodeId); + }, + async heartbeat(nodeId: string, input: NodeHeartbeatInput) { + const index = nodes.findIndex((candidate) => candidate.id === nodeId); + + if (index < 0) { + return undefined; + } + + nodes[index] = { + ...nodes[index], + agentVersion: input.agentVersion, + hostname: input.hostname, + ipAddresses: input.ipAddresses, + lastSeenAt: new Date().toISOString(), + runtime: input.runtime, + status: input.status, + }; + + return nodes[index]; + }, + async list() { + return nodes; + }, + async rotateCredential() { + throw new Error("not implemented"); + }, + async updateInterface() { + throw new Error("not implemented"); + }, + async update() { + throw new Error("not implemented"); + }, + }; +} + +export function requirePermission(): RequirePermission { + return () => async (_c, next) => { + await next(); + }; +} + +export function memoryMeterFrameStore(): MeterFrameStore { + const frames: MeterFrame[] = []; + + return { + async history(nodeId, limit = frames.length) { + return frames.filter((frame) => frame.nodeId === nodeId).slice(0, limit); + }, + async latest() { + return frames[0]; + }, + async save(frame) { + frames.unshift(frame); + + return { + frame, + receivedAt: new Date().toISOString(), + }; + }, + }; +} + +export function memoryRecordingStore(recordings: RecordingSummary[] = []): RecordingStore { + return { + async create(recording) { + recordings.unshift(recording); + }, + async delete(recordingId) { + const index = recordings.findIndex((candidate) => candidate.id === recordingId); + + if (index < 0) { + return undefined; + } + + const [deleted] = recordings.splice(index, 1); + + return deleted; + }, + async find(recordingId) { + return recordings.find((candidate) => candidate.id === recordingId); + }, + async list() { + return recordings; + }, + async save(recording) { + const index = recordings.findIndex((candidate) => candidate.id === recording.id); + + if (index >= 0) { + recordings[index] = recording; + } + }, + async transition(recording, allowedFrom) { + const index = recordings.findIndex((candidate) => candidate.id === recording.id); + const current = recordings[index]; + + if (!current || !allowedFrom.includes(current.status)) { + return undefined; + } + + recordings[index] = recording; + + return recording; + }, + }; +} + +export function recordAuditEvent( + auditStore: ReturnType, +): RecordAuditEvent { + return async (_c, input) => { + const event: AuditEvent = { + action: input.action, + actor: input.actor ?? { + id: "anonymous", + name: "Anonymous", + roles: [], + type: "user", + }, + actorContext: {}, + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: input.details ?? {}, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; +} + +export function memorySettingsStore(profiles: RecordingProfile[]): SettingsStore { + return { + async assignChannelMapTemplate() { + throw new Error("not implemented"); + }, + async createChannelMapTemplate() { + throw new Error("not implemented"); + }, + async findChannelMapTemplate() { + return undefined; + }, + async findRecordingProfile(profileId) { + return profiles.find((profile) => profile.id === profileId); + }, + async findWatchdogPolicy() { + return undefined; + }, + async listChannelMapAssignments() { + return []; + }, + async listChannelMapTemplates() { + return []; + }, + async listRecordingProfiles() { + return profiles; + }, + async listWatchdogPolicies() { + return []; + }, + async rollbackChannelMapAssignment() { + return undefined; + }, + async updateChannelMapTemplate() { + return undefined; + }, + async updateRecordingProfile() { + return undefined; + }, + async updateWatchdogPolicy() { + return undefined; + }, + }; +} + +export function auth(): AuthResult { + return { user: user() }; +} + +export function user(): CurrentUser { + return { + email: "agent-route@example.com", + groups: [], + id: "user_agent_route", + name: "Agent Route User", + permissions: [ + "recording:create", + "recording:download", + "recording:playback", + "recording:read", + ] satisfies Permission[], + provider: "local", + resourceGrants: [], + roles: ["operator"], + }; +} + +export function node(): RecorderNode { + return { + agentVersion: "0.1.0", + alias: "Agent Route Node", + hostname: "agent-route-node", + id: "node_agent_test", + interfaces: [], + ipAddresses: ["127.0.0.1"], + lastSeenAt: "2026-06-18T12:00:00.000Z", + location: { + room: "Test Room", + site: "Test Site", + }, + status: "recording", + tags: [], + }; +} + +export function recording(): RecordingSummary { + return { + cached: false, + durationSeconds: 900, + folder: "Meetings/2026", + healthStatus: "unknown", + id: "rec_agent_failure", + name: "Agent Failure Test", + nodeId: "node_agent_test", + recordedAt: "2026-06-18T12:00:00.000Z", + source: "schedule", + status: "recording", + tags: ["voice"], + }; +} + +export function flacProfile(): RecordingProfile { + return { + bitrateKbps: 256, + channelMode: "stereo", + codec: "flac", + id: "voice-flac", + name: "Voice FLAC", + silenceDetectionEnabled: false, + silenceSkipEnabled: false, + vbr: false, + }; +} + +export function wavFile(samples: number[]) { + const dataSize = samples.length * 2; + const buffer = Buffer.alloc(44 + dataSize); + + buffer.write("RIFF", 0, "ascii"); + buffer.writeUInt32LE(36 + dataSize, 4); + buffer.write("WAVE", 8, "ascii"); + buffer.write("fmt ", 12, "ascii"); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(1, 22); + buffer.writeUInt32LE(48_000, 24); + buffer.writeUInt32LE(96_000, 28); + buffer.writeUInt16LE(2, 32); + buffer.writeUInt16LE(16, 34); + buffer.write("data", 36, "ascii"); + buffer.writeUInt32LE(dataSize, 40); + + samples.forEach((sample, index) => buffer.writeInt16LE(sample, 44 + index * 2)); + + return buffer; +} diff --git a/apps/api/test/agent-routes-recording-lifecycle.test.ts b/apps/api/test/agent-routes-recording-lifecycle.test.ts new file mode 100644 index 0000000..78f003c --- /dev/null +++ b/apps/api/test/agent-routes-recording-lifecycle.test.ts @@ -0,0 +1,365 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Hono } from "hono"; +import { defaultVoiceRecordingProfile } from "@rakkr/shared"; +import type { RecordingJob, RecordingSummary } from "@rakkr/shared"; +import type { AppBindings } from "../src/http-types.js"; +import { + auth, + createAuditStore, + createHealthEventStore, + createUploadPolicy, + memoryMeterFrameStore, + memoryNodeStore, + memoryRecordingStore, + memorySettingsStore, + node, + recordAuditEvent, + registerAgentRoutes, + registerRecordingRoutes, + requirePermission, + user, + wavFile, +} from "./agent-routes-harness.js"; + +test("ad hoc recording completes through agent cache attach and exposes cached media", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const healthEventStore = createHealthEventStore("", []); + const lifecycleNode = { + ...node(), + alias: "Lifecycle Recorder", + id: `node_lifecycle_${randomUUID()}`, + }; + const nodeStore = memoryNodeStore([lifecycleNode]); + const recordingStore = memoryRecordingStore(); + const policy = await createUploadPolicy({ + enabled: true, + id: `upload-policy-lifecycle-${randomUUID()}`, + maxAttempts: 2, + name: "Lifecycle Auto Stub", + trigger: "on_recording_cached", + }); + + registerRecordingRoutes({ + app, + currentAuth: () => auth(), + currentUser: () => user(), + nodeStore, + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + requirePermission: requirePermission(), + scopedNodes: () => nodeStore.list(), + scopedRecordings: () => recordingStore.list(), + settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), + }); + registerAgentRoutes({ + app, + healthEventStore, + meterFrameStore: memoryMeterFrameStore(), + nodeStore, + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), + }); + + const started = await app.request("/api/v1/recordings", { + body: JSON.stringify({ + name: "Lifecycle Recording", + nodeId: lifecycleNode.id, + tags: ["voice", "lifecycle"], + uploadPolicyIds: [policy.id], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const startedBody = (await started.json()) as { data: RecordingSummary; job: RecordingJob }; + const next = await app.request(`/api/v1/nodes/${lifecycleNode.id}/recording-jobs/next`, { + headers: { authorization: "Bearer node-token" }, + }); + const nextBody = (await next.json()) as { data: RecordingJob }; + const claimed = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/claim`, { + headers: { authorization: "Bearer node-token" }, + method: "POST", + }); + const heartbeat = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/heartbeat`, { + headers: { authorization: "Bearer node-token" }, + method: "POST", + }); + const wavBytes = wavFile([0, 12_000, -24_000, 6000]); + const attached = await app.request(`/api/v1/recordings/${startedBody.data.id}/cache-file`, { + body: wavBytes, + headers: { + authorization: "Bearer node-token", + "content-type": "audio/wav", + "x-rakkr-duration-seconds": "2", + "x-rakkr-file-name": "lifecycle.wav", + "x-rakkr-recording-job-id": startedBody.job.id, + }, + method: "PUT", + }); + const attachedBody = (await attached.json()) as { + data: { + recording: RecordingSummary; + uploadQueueItem?: { recordingId: string; uploadPolicyId?: string }; + }; + }; + const completedJob = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}`, { + headers: { authorization: "Bearer node-token" }, + }); + const completedJobBody = (await completedJob.json()) as { data: RecordingJob }; + const playback = await app.request(`/api/v1/recordings/${startedBody.data.id}/playback`, { + method: "POST", + }); + const download = await app.request(`/api/v1/recordings/${startedBody.data.id}/download`, { + method: "POST", + }); + const stream = await app.request(`/api/v1/recordings/${startedBody.data.id}/stream`); + const file = await app.request(`/api/v1/recordings/${startedBody.data.id}/file`); + const cached = await recordingStore.find(startedBody.data.id); + const [cacheAudit] = await auditStore.list({ action: "recordings.cache_file.attach.succeeded" }); + const [autoQueueAudit] = await auditStore.list({ + action: "recordings.upload_queue.auto_enqueue.succeeded", + }); + + assert.equal(started.status, 202); + assert.equal(next.status, 200); + assert.equal(nextBody.data.id, startedBody.job.id); + assert.equal(claimed.status, 200); + assert.equal(heartbeat.status, 200); + assert.equal(attached.status, 201); + assert.equal(attachedBody.data.recording.cached, true); + assert.equal(attachedBody.data.recording.status, "cached"); + assert.equal(attachedBody.data.recording.healthStatus, "healthy"); + assert.equal(attachedBody.data.recording.cachePath, `ad-hoc/${startedBody.data.id}.wav`); + assert.equal(attachedBody.data.recording.durationSeconds, 2); + assert.equal(attachedBody.data.recording.waveformPreview?.peaks.length, 4); + assert.equal(attachedBody.data.uploadQueueItem?.recordingId, startedBody.data.id); + assert.equal(attachedBody.data.uploadQueueItem?.uploadPolicyId, policy.id); + assert.equal(completedJob.status, 200); + assert.equal(completedJobBody.data.status, "completed"); + assert.equal(cached?.status, "cached"); + assert.equal(cached?.healthStatus, "healthy"); + assert.equal(playback.status, 202); + assert.equal(download.status, 202); + assert.equal(stream.status, 200); + assert.equal(stream.headers.get("content-type"), "audio/wav"); + assert.equal((await stream.arrayBuffer()).byteLength, wavBytes.byteLength); + assert.equal(file.status, 200); + assert.equal( + file.headers.get("content-disposition"), + 'attachment; filename="Lifecycle Recording.wav"', + ); + assert.equal(cacheAudit?.details.jobStatus, "completed"); + assert.equal(autoQueueAudit?.details.uploadPolicyId, policy.id); +}); + +test("claim-next lets one node claim multiple queued recordings independently", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const healthEventStore = createHealthEventStore("", []); + const lifecycleNode = { + ...node(), + alias: "Concurrent Lifecycle Recorder", + id: `node_concurrent_lifecycle_${randomUUID()}`, + interfaces: [ + { + alias: "Concurrent X32", + backend: "alsa" as const, + channelCount: 4, + channels: [ + { alias: "Ch 1", index: 1 }, + { alias: "Ch 2", index: 2 }, + { alias: "Ch 3", index: 3 }, + { alias: "Ch 4", index: 4 }, + ], + id: "iface_concurrent_x32", + sampleRates: [48_000], + systemName: "Concurrent X32", + systemRef: "hw:CARD=X32,DEV=0", + }, + ], + recordingCapacity: { maxConcurrentRecordings: 2 }, + }; + const nodeStore = memoryNodeStore([lifecycleNode]); + const recordingStore = memoryRecordingStore(); + + registerRecordingRoutes({ + app, + currentAuth: () => auth(), + currentUser: () => user(), + nodeStore, + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + requirePermission: requirePermission(), + scopedNodes: () => nodeStore.list(), + scopedRecordings: () => recordingStore.list(), + settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), + }); + registerAgentRoutes({ + app, + healthEventStore, + meterFrameStore: memoryMeterFrameStore(), + nodeStore, + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), + }); + + const firstStarted = await app.request("/api/v1/recordings", { + body: JSON.stringify({ + captureChannelSelection: [1, 2], + captureInterfaceId: "iface_concurrent_x32", + channelMode: "stereo", + name: "Concurrent Recording A", + nodeId: lifecycleNode.id, + tags: ["voice", "concurrent"], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const secondStarted = await app.request("/api/v1/recordings", { + body: JSON.stringify({ + captureChannelSelection: [3, 4], + captureInterfaceId: "iface_concurrent_x32", + channelMode: "stereo", + name: "Concurrent Recording B", + nodeId: lifecycleNode.id, + tags: ["voice", "concurrent"], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const firstStartedBody = (await firstStarted.json()) as { + data: RecordingSummary; + job: RecordingJob; + }; + const secondStartedBody = (await secondStarted.json()) as { + data: RecordingSummary; + job: RecordingJob; + }; + const firstClaim = await app.request( + `/api/v1/nodes/${lifecycleNode.id}/recording-jobs/claim-next`, + { + headers: { authorization: "Bearer node-token" }, + method: "POST", + }, + ); + const secondClaim = await app.request( + `/api/v1/nodes/${lifecycleNode.id}/recording-jobs/claim-next`, + { + headers: { authorization: "Bearer node-token" }, + method: "POST", + }, + ); + const emptyClaim = await app.request( + `/api/v1/nodes/${lifecycleNode.id}/recording-jobs/claim-next`, + { + headers: { authorization: "Bearer node-token" }, + method: "POST", + }, + ); + const firstClaimBody = (await firstClaim.json()) as { data: RecordingJob }; + const secondClaimBody = (await secondClaim.json()) as { data: RecordingJob }; + const claimedJobIds = new Set([firstClaimBody.data.id, secondClaimBody.data.id]); + const expectedJobIds = new Set([firstStartedBody.job.id, secondStartedBody.job.id]); + const firstRecording = await recordingStore.find(firstStartedBody.data.id); + const secondRecording = await recordingStore.find(secondStartedBody.data.id); + const claimAudits = ( + await auditStore.list({ + action: "recording_jobs.claim_next.succeeded", + }) + ).filter((event) => event.target.type === "recording_job"); + assert.equal(firstStarted.status, 202); + assert.equal(secondStarted.status, 202); + assert.equal(firstClaim.status, 200); + assert.equal(secondClaim.status, 200); + assert.equal(emptyClaim.status, 204); + assert.deepEqual(claimedJobIds, expectedJobIds); + assert.equal(firstClaimBody.data.status, "running"); + assert.equal(secondClaimBody.data.status, "running"); + assert.equal(firstRecording?.status, "recording"); + assert.equal(secondRecording?.status, "recording"); + assert.equal(claimAudits.length, 2); +}); +test("controller stop request survives agent cancellation as completed recording", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const healthEventStore = createHealthEventStore("", []); + const lifecycleNode = { + ...node(), + alias: "Stop Lifecycle Recorder", + id: `node_stop_lifecycle_${randomUUID()}`, + }; + const nodeStore = memoryNodeStore([lifecycleNode]); + const recordingStore = memoryRecordingStore(); + + registerRecordingRoutes({ + app, + currentAuth: () => auth(), + currentUser: () => user(), + nodeStore, + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + requirePermission: requirePermission(), + scopedNodes: () => nodeStore.list(), + scopedRecordings: () => recordingStore.list(), + settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), + }); + registerAgentRoutes({ + app, + healthEventStore, + meterFrameStore: memoryMeterFrameStore(), + nodeStore, + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), + }); + + const started = await app.request("/api/v1/recordings", { + body: JSON.stringify({ + name: "Stop Lifecycle Recording", + nodeId: lifecycleNode.id, + tags: ["voice", "stop-lifecycle"], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const startedBody = (await started.json()) as { data: RecordingSummary; job: RecordingJob }; + const claimed = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/claim`, { + headers: { authorization: "Bearer node-token" }, + method: "POST", + }); + const stopped = await app.request(`/api/v1/recordings/${startedBody.data.id}/stop`, { + method: "POST", + }); + const stoppedBody = (await stopped.json()) as { data: RecordingSummary }; + const cancelled = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/cancelled`, { + headers: { + authorization: "Bearer node-token", + "x-rakkr-reason": "controller_stop_requested", + }, + method: "POST", + }); + const cancelledBody = (await cancelled.json()) as { data: RecordingJob }; + const updated = await recordingStore.find(startedBody.data.id); + const healthEvents = await healthEventStore.list({ recordingId: startedBody.data.id }); + const [stopAudit] = await auditStore.list({ action: "recordings.stop.succeeded" }); + const [cancelAudit] = await auditStore.list({ + action: "recording_jobs.cancelled.succeeded", + }); + + assert.equal(started.status, 202); + assert.equal(claimed.status, 200); + assert.equal(stopped.status, 200); + assert.equal(stoppedBody.data.status, "completed"); + assert.equal(stopAudit?.details.jobStatus, "stop_requested"); + assert.equal(cancelled.status, 200); + assert.equal(cancelledBody.data.status, "cancelled"); + assert.equal(updated?.status, "completed"); + assert.equal(updated?.healthStatus, "healthy"); + assert.equal(healthEvents.length, 0); + assert.equal(cancelAudit?.details.reason, "controller_stop_requested"); + assert.equal(cancelAudit?.details.recordingStatus, "completed"); +}); diff --git a/apps/api/test/agent-routes.test.ts b/apps/api/test/agent-routes.test.ts index 69ee5d8..3e97cfc 100644 --- a/apps/api/test/agent-routes.test.ts +++ b/apps/api/test/agent-routes.test.ts @@ -1,44 +1,23 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import test from "node:test"; import { Hono } from "hono"; -import { defaultVoiceRecordingProfile } from "@rakkr/shared"; -import type { - AuditEvent, - CurrentUser, - MeterFrame, - Permission, - RecorderNode, - RecordingJob, - RecordingProfile, - RecordingSummary, -} from "@rakkr/shared"; -import type { AuthResult } from "../src/auth-service.js"; -import type { AppBindings, RecordAuditEvent, RequirePermission } from "../src/http-types.js"; -import type { MeterFrameStore } from "../src/meter-store.js"; -import type { NodeHeartbeatInput, NodeStore } from "../src/node-store.js"; -import type { RecordingStore } from "../src/recording-store.js"; +import type { AppBindings } from "../src/http-types.js"; import type { SettingsStore } from "../src/settings-store.js"; -const agentRoot = await mkdtemp(path.join(tmpdir(), "rakkr-agent-routes-")); -process.env.DATABASE_URL = ""; -process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(agentRoot, "jobs.json"); -process.env.RAKKR_RECORDING_CACHE_DIR = path.join(agentRoot, "cache"); -process.env.RAKKR_RETENTION_POLICY_STORE_PATH = path.join(agentRoot, "retention-policies.json"); -process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(agentRoot, "upload-policies.json"); -process.env.RAKKR_UPLOAD_QUEUE_STORE_PATH = path.join(agentRoot, "upload-queue.json"); -const { createAuditStore } = await import("../src/audit-store.js"); -const { registerAgentRoutes } = await import("../src/agent-routes.js"); -const { createHealthEventStore } = await import("../src/health-store.js"); -const { createRecordingJob } = await import("../src/recording-jobs.js"); -const { createRetentionPolicy } = await import("../src/retention-policies.js"); -const { registerRecordingRoutes } = await import("../src/recording-routes.js"); -const { createUploadPolicy } = await import("../src/upload-policies.js"); -test.after(async () => { - await rm(agentRoot, { force: true, recursive: true }); -}); +import { + createAuditStore, + createHealthEventStore, + createRecordingJob, + createRetentionPolicy, + flacProfile, + memoryMeterFrameStore, + memoryNodeStore, + memoryRecordingStore, + node, + recordAuditEvent, + recording, + registerAgentRoutes, +} from "./agent-routes-harness.js"; test("agent failed job marks recording metadata failed", async () => { const app = new Hono(); @@ -353,631 +332,3 @@ test("recording job carries recorder-cache retention policy", async () => { policyId: policy.id, }); }); - -test("ad hoc recording completes through agent cache attach and exposes cached media", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const healthEventStore = createHealthEventStore("", []); - const lifecycleNode = { - ...node(), - alias: "Lifecycle Recorder", - id: `node_lifecycle_${randomUUID()}`, - }; - const nodeStore = memoryNodeStore([lifecycleNode]); - const recordingStore = memoryRecordingStore(); - const policy = await createUploadPolicy({ - enabled: true, - id: `upload-policy-lifecycle-${randomUUID()}`, - maxAttempts: 2, - name: "Lifecycle Auto Stub", - trigger: "on_recording_cached", - }); - - registerRecordingRoutes({ - app, - currentAuth: () => auth(), - currentUser: () => user(), - nodeStore, - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - requirePermission: requirePermission(), - scopedNodes: () => nodeStore.list(), - scopedRecordings: () => recordingStore.list(), - settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), - }); - registerAgentRoutes({ - app, - healthEventStore, - meterFrameStore: memoryMeterFrameStore(), - nodeStore, - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), - }); - - const started = await app.request("/api/v1/recordings", { - body: JSON.stringify({ - name: "Lifecycle Recording", - nodeId: lifecycleNode.id, - tags: ["voice", "lifecycle"], - uploadPolicyIds: [policy.id], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const startedBody = (await started.json()) as { data: RecordingSummary; job: RecordingJob }; - const next = await app.request(`/api/v1/nodes/${lifecycleNode.id}/recording-jobs/next`, { - headers: { authorization: "Bearer node-token" }, - }); - const nextBody = (await next.json()) as { data: RecordingJob }; - const claimed = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/claim`, { - headers: { authorization: "Bearer node-token" }, - method: "POST", - }); - const heartbeat = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/heartbeat`, { - headers: { authorization: "Bearer node-token" }, - method: "POST", - }); - const wavBytes = wavFile([0, 12_000, -24_000, 6000]); - const attached = await app.request(`/api/v1/recordings/${startedBody.data.id}/cache-file`, { - body: wavBytes, - headers: { - authorization: "Bearer node-token", - "content-type": "audio/wav", - "x-rakkr-duration-seconds": "2", - "x-rakkr-file-name": "lifecycle.wav", - "x-rakkr-recording-job-id": startedBody.job.id, - }, - method: "PUT", - }); - const attachedBody = (await attached.json()) as { - data: { - recording: RecordingSummary; - uploadQueueItem?: { recordingId: string; uploadPolicyId?: string }; - }; - }; - const completedJob = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}`, { - headers: { authorization: "Bearer node-token" }, - }); - const completedJobBody = (await completedJob.json()) as { data: RecordingJob }; - const playback = await app.request(`/api/v1/recordings/${startedBody.data.id}/playback`, { - method: "POST", - }); - const download = await app.request(`/api/v1/recordings/${startedBody.data.id}/download`, { - method: "POST", - }); - const stream = await app.request(`/api/v1/recordings/${startedBody.data.id}/stream`); - const file = await app.request(`/api/v1/recordings/${startedBody.data.id}/file`); - const cached = await recordingStore.find(startedBody.data.id); - const [cacheAudit] = await auditStore.list({ action: "recordings.cache_file.attach.succeeded" }); - const [autoQueueAudit] = await auditStore.list({ - action: "recordings.upload_queue.auto_enqueue.succeeded", - }); - - assert.equal(started.status, 202); - assert.equal(next.status, 200); - assert.equal(nextBody.data.id, startedBody.job.id); - assert.equal(claimed.status, 200); - assert.equal(heartbeat.status, 200); - assert.equal(attached.status, 201); - assert.equal(attachedBody.data.recording.cached, true); - assert.equal(attachedBody.data.recording.status, "cached"); - assert.equal(attachedBody.data.recording.healthStatus, "healthy"); - assert.equal(attachedBody.data.recording.cachePath, `ad-hoc/${startedBody.data.id}.wav`); - assert.equal(attachedBody.data.recording.durationSeconds, 2); - assert.equal(attachedBody.data.recording.waveformPreview?.peaks.length, 4); - assert.equal(attachedBody.data.uploadQueueItem?.recordingId, startedBody.data.id); - assert.equal(attachedBody.data.uploadQueueItem?.uploadPolicyId, policy.id); - assert.equal(completedJob.status, 200); - assert.equal(completedJobBody.data.status, "completed"); - assert.equal(cached?.status, "cached"); - assert.equal(cached?.healthStatus, "healthy"); - assert.equal(playback.status, 202); - assert.equal(download.status, 202); - assert.equal(stream.status, 200); - assert.equal(stream.headers.get("content-type"), "audio/wav"); - assert.equal((await stream.arrayBuffer()).byteLength, wavBytes.byteLength); - assert.equal(file.status, 200); - assert.equal( - file.headers.get("content-disposition"), - 'attachment; filename="Lifecycle Recording.wav"', - ); - assert.equal(cacheAudit?.details.jobStatus, "completed"); - assert.equal(autoQueueAudit?.details.uploadPolicyId, policy.id); -}); - -test("claim-next lets one node claim multiple queued recordings independently", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const healthEventStore = createHealthEventStore("", []); - const lifecycleNode = { - ...node(), - alias: "Concurrent Lifecycle Recorder", - id: `node_concurrent_lifecycle_${randomUUID()}`, - interfaces: [ - { - alias: "Concurrent X32", - backend: "alsa" as const, - channelCount: 4, - channels: [ - { alias: "Ch 1", index: 1 }, - { alias: "Ch 2", index: 2 }, - { alias: "Ch 3", index: 3 }, - { alias: "Ch 4", index: 4 }, - ], - id: "iface_concurrent_x32", - sampleRates: [48_000], - systemName: "Concurrent X32", - systemRef: "hw:CARD=X32,DEV=0", - }, - ], - recordingCapacity: { maxConcurrentRecordings: 2 }, - }; - const nodeStore = memoryNodeStore([lifecycleNode]); - const recordingStore = memoryRecordingStore(); - - registerRecordingRoutes({ - app, - currentAuth: () => auth(), - currentUser: () => user(), - nodeStore, - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - requirePermission: requirePermission(), - scopedNodes: () => nodeStore.list(), - scopedRecordings: () => recordingStore.list(), - settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), - }); - registerAgentRoutes({ - app, - healthEventStore, - meterFrameStore: memoryMeterFrameStore(), - nodeStore, - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), - }); - - const firstStarted = await app.request("/api/v1/recordings", { - body: JSON.stringify({ - captureChannelSelection: [1, 2], - captureInterfaceId: "iface_concurrent_x32", - channelMode: "stereo", - name: "Concurrent Recording A", - nodeId: lifecycleNode.id, - tags: ["voice", "concurrent"], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const secondStarted = await app.request("/api/v1/recordings", { - body: JSON.stringify({ - captureChannelSelection: [3, 4], - captureInterfaceId: "iface_concurrent_x32", - channelMode: "stereo", - name: "Concurrent Recording B", - nodeId: lifecycleNode.id, - tags: ["voice", "concurrent"], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const firstStartedBody = (await firstStarted.json()) as { - data: RecordingSummary; - job: RecordingJob; - }; - const secondStartedBody = (await secondStarted.json()) as { - data: RecordingSummary; - job: RecordingJob; - }; - const firstClaim = await app.request( - `/api/v1/nodes/${lifecycleNode.id}/recording-jobs/claim-next`, - { - headers: { authorization: "Bearer node-token" }, - method: "POST", - }, - ); - const secondClaim = await app.request( - `/api/v1/nodes/${lifecycleNode.id}/recording-jobs/claim-next`, - { - headers: { authorization: "Bearer node-token" }, - method: "POST", - }, - ); - const emptyClaim = await app.request( - `/api/v1/nodes/${lifecycleNode.id}/recording-jobs/claim-next`, - { - headers: { authorization: "Bearer node-token" }, - method: "POST", - }, - ); - const firstClaimBody = (await firstClaim.json()) as { data: RecordingJob }; - const secondClaimBody = (await secondClaim.json()) as { data: RecordingJob }; - const claimedJobIds = new Set([firstClaimBody.data.id, secondClaimBody.data.id]); - const expectedJobIds = new Set([firstStartedBody.job.id, secondStartedBody.job.id]); - const firstRecording = await recordingStore.find(firstStartedBody.data.id); - const secondRecording = await recordingStore.find(secondStartedBody.data.id); - const claimAudits = ( - await auditStore.list({ - action: "recording_jobs.claim_next.succeeded", - }) - ).filter((event) => event.target.type === "recording_job"); - assert.equal(firstStarted.status, 202); - assert.equal(secondStarted.status, 202); - assert.equal(firstClaim.status, 200); - assert.equal(secondClaim.status, 200); - assert.equal(emptyClaim.status, 204); - assert.deepEqual(claimedJobIds, expectedJobIds); - assert.equal(firstClaimBody.data.status, "running"); - assert.equal(secondClaimBody.data.status, "running"); - assert.equal(firstRecording?.status, "recording"); - assert.equal(secondRecording?.status, "recording"); - assert.equal(claimAudits.length, 2); -}); -test("controller stop request survives agent cancellation as completed recording", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const healthEventStore = createHealthEventStore("", []); - const lifecycleNode = { - ...node(), - alias: "Stop Lifecycle Recorder", - id: `node_stop_lifecycle_${randomUUID()}`, - }; - const nodeStore = memoryNodeStore([lifecycleNode]); - const recordingStore = memoryRecordingStore(); - - registerRecordingRoutes({ - app, - currentAuth: () => auth(), - currentUser: () => user(), - nodeStore, - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - requirePermission: requirePermission(), - scopedNodes: () => nodeStore.list(), - scopedRecordings: () => recordingStore.list(), - settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), - }); - registerAgentRoutes({ - app, - healthEventStore, - meterFrameStore: memoryMeterFrameStore(), - nodeStore, - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - settingsStore: memorySettingsStore([defaultVoiceRecordingProfile]), - }); - - const started = await app.request("/api/v1/recordings", { - body: JSON.stringify({ - name: "Stop Lifecycle Recording", - nodeId: lifecycleNode.id, - tags: ["voice", "stop-lifecycle"], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const startedBody = (await started.json()) as { data: RecordingSummary; job: RecordingJob }; - const claimed = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/claim`, { - headers: { authorization: "Bearer node-token" }, - method: "POST", - }); - const stopped = await app.request(`/api/v1/recordings/${startedBody.data.id}/stop`, { - method: "POST", - }); - const stoppedBody = (await stopped.json()) as { data: RecordingSummary }; - const cancelled = await app.request(`/api/v1/recording-jobs/${startedBody.job.id}/cancelled`, { - headers: { - authorization: "Bearer node-token", - "x-rakkr-reason": "controller_stop_requested", - }, - method: "POST", - }); - const cancelledBody = (await cancelled.json()) as { data: RecordingJob }; - const updated = await recordingStore.find(startedBody.data.id); - const healthEvents = await healthEventStore.list({ recordingId: startedBody.data.id }); - const [stopAudit] = await auditStore.list({ action: "recordings.stop.succeeded" }); - const [cancelAudit] = await auditStore.list({ - action: "recording_jobs.cancelled.succeeded", - }); - - assert.equal(started.status, 202); - assert.equal(claimed.status, 200); - assert.equal(stopped.status, 200); - assert.equal(stoppedBody.data.status, "completed"); - assert.equal(stopAudit?.details.jobStatus, "stop_requested"); - assert.equal(cancelled.status, 200); - assert.equal(cancelledBody.data.status, "cancelled"); - assert.equal(updated?.status, "completed"); - assert.equal(updated?.healthStatus, "healthy"); - assert.equal(healthEvents.length, 0); - assert.equal(cancelAudit?.details.reason, "controller_stop_requested"); - assert.equal(cancelAudit?.details.recordingStatus, "completed"); -}); - -function memoryNodeStore(nodes: RecorderNode[] = [node()]): NodeStore { - return { - async authenticateCredential(token) { - return token === "node-token" - ? { - credentialId: "cred_agent_test", - nodeId: nodes[0]?.id ?? "node_agent_test", - tokenPrefix: "node-token", - } - : undefined; - }, - async enroll() { - throw new Error("not implemented"); - }, - async find(nodeId) { - return nodes.find((candidate) => candidate.id === nodeId); - }, - async heartbeat(nodeId: string, input: NodeHeartbeatInput) { - const index = nodes.findIndex((candidate) => candidate.id === nodeId); - - if (index < 0) { - return undefined; - } - - nodes[index] = { - ...nodes[index], - agentVersion: input.agentVersion, - hostname: input.hostname, - ipAddresses: input.ipAddresses, - lastSeenAt: new Date().toISOString(), - runtime: input.runtime, - status: input.status, - }; - - return nodes[index]; - }, - async list() { - return nodes; - }, - async rotateCredential() { - throw new Error("not implemented"); - }, - async updateInterface() { - throw new Error("not implemented"); - }, - async update() { - throw new Error("not implemented"); - }, - }; -} - -function requirePermission(): RequirePermission { - return () => async (_c, next) => { - await next(); - }; -} - -function memoryMeterFrameStore(): MeterFrameStore { - const frames: MeterFrame[] = []; - - return { - async history(nodeId, limit = frames.length) { - return frames.filter((frame) => frame.nodeId === nodeId).slice(0, limit); - }, - async latest() { - return frames[0]; - }, - async save(frame) { - frames.unshift(frame); - - return { - frame, - receivedAt: new Date().toISOString(), - }; - }, - }; -} - -function memoryRecordingStore(recordings: RecordingSummary[] = []): RecordingStore { - return { - async create(recording) { - recordings.unshift(recording); - }, - async delete(recordingId) { - const index = recordings.findIndex((candidate) => candidate.id === recordingId); - - if (index < 0) { - return undefined; - } - - const [deleted] = recordings.splice(index, 1); - - return deleted; - }, - async find(recordingId) { - return recordings.find((candidate) => candidate.id === recordingId); - }, - async list() { - return recordings; - }, - async save(recording) { - const index = recordings.findIndex((candidate) => candidate.id === recording.id); - - if (index >= 0) { - recordings[index] = recording; - } - }, - async transition(recording, allowedFrom) { - const index = recordings.findIndex((candidate) => candidate.id === recording.id); - const current = recordings[index]; - - if (!current || !allowedFrom.includes(current.status)) { - return undefined; - } - - recordings[index] = recording; - - return recording; - }, - }; -} - -function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { - return async (_c, input) => { - const event: AuditEvent = { - action: input.action, - actor: input.actor ?? { - id: "anonymous", - name: "Anonymous", - roles: [], - type: "user", - }, - actorContext: {}, - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: input.details ?? {}, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; - }; -} - -function memorySettingsStore(profiles: RecordingProfile[]): SettingsStore { - return { - async assignChannelMapTemplate() { - throw new Error("not implemented"); - }, - async createChannelMapTemplate() { - throw new Error("not implemented"); - }, - async findChannelMapTemplate() { - return undefined; - }, - async findRecordingProfile(profileId) { - return profiles.find((profile) => profile.id === profileId); - }, - async findWatchdogPolicy() { - return undefined; - }, - async listChannelMapAssignments() { - return []; - }, - async listChannelMapTemplates() { - return []; - }, - async listRecordingProfiles() { - return profiles; - }, - async listWatchdogPolicies() { - return []; - }, - async rollbackChannelMapAssignment() { - return undefined; - }, - async updateChannelMapTemplate() { - return undefined; - }, - async updateRecordingProfile() { - return undefined; - }, - async updateWatchdogPolicy() { - return undefined; - }, - }; -} - -function auth(): AuthResult { - return { user: user() }; -} - -function user(): CurrentUser { - return { - email: "agent-route@example.com", - groups: [], - id: "user_agent_route", - name: "Agent Route User", - permissions: [ - "recording:create", - "recording:download", - "recording:playback", - "recording:read", - ] satisfies Permission[], - provider: "local", - resourceGrants: [], - roles: ["operator"], - }; -} - -function node(): RecorderNode { - return { - agentVersion: "0.1.0", - alias: "Agent Route Node", - hostname: "agent-route-node", - id: "node_agent_test", - interfaces: [], - ipAddresses: ["127.0.0.1"], - lastSeenAt: "2026-06-18T12:00:00.000Z", - location: { - room: "Test Room", - site: "Test Site", - }, - status: "recording", - tags: [], - }; -} - -function recording(): RecordingSummary { - return { - cached: false, - durationSeconds: 900, - folder: "Meetings/2026", - healthStatus: "unknown", - id: "rec_agent_failure", - name: "Agent Failure Test", - nodeId: "node_agent_test", - recordedAt: "2026-06-18T12:00:00.000Z", - source: "schedule", - status: "recording", - tags: ["voice"], - }; -} - -function flacProfile(): RecordingProfile { - return { - bitrateKbps: 256, - channelMode: "stereo", - codec: "flac", - id: "voice-flac", - name: "Voice FLAC", - silenceDetectionEnabled: false, - silenceSkipEnabled: false, - vbr: false, - }; -} - -function wavFile(samples: number[]) { - const dataSize = samples.length * 2; - const buffer = Buffer.alloc(44 + dataSize); - - buffer.write("RIFF", 0, "ascii"); - buffer.writeUInt32LE(36 + dataSize, 4); - buffer.write("WAVE", 8, "ascii"); - buffer.write("fmt ", 12, "ascii"); - buffer.writeUInt32LE(16, 16); - buffer.writeUInt16LE(1, 20); - buffer.writeUInt16LE(1, 22); - buffer.writeUInt32LE(48_000, 24); - buffer.writeUInt32LE(96_000, 28); - buffer.writeUInt16LE(2, 32); - buffer.writeUInt16LE(16, 34); - buffer.write("data", 36, "ascii"); - buffer.writeUInt32LE(dataSize, 40); - - samples.forEach((sample, index) => buffer.writeInt16LE(sample, 44 + index * 2)); - - return buffer; -} diff --git a/apps/api/test/recording-job-export-harness.ts b/apps/api/test/recording-job-export-harness.ts new file mode 100644 index 0000000..f32939f --- /dev/null +++ b/apps/api/test/recording-job-export-harness.ts @@ -0,0 +1,272 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { Hono } from "hono"; +import type { + AuditEvent, + CurrentUser, + Permission, + RecorderNode, + MeterFrame, + RecordingJob, + RecordingProfile, + RecordingSummary, +} from "@rakkr/shared"; +import { defaultVoiceRecordingProfile } from "@rakkr/shared"; +import type { + AppBindings, + AuditTarget, + RecordAuditEvent, + RequirePermission, +} from "../src/http-types.js"; +import type { MeterFrameStore } from "../src/meter-store.js"; +import type { NodeStore } from "../src/node-store.js"; +import type { RecordingStore } from "../src/recording-store.js"; +import type { SettingsStore } from "../src/settings-store.js"; + +export { memoryRecordingStore } from "./recording-store-mock.js"; + +export const routeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-recording-job-export-")); +process.env.DATABASE_URL = ""; +process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(routeRoot, "jobs.json"); +process.env.RAKKR_RETENTION_POLICY_STORE_PATH = path.join(routeRoot, "retention-policies.json"); + +export const { createAuditStore } = await import("../src/audit-store.js"); +export const { registerAgentRoutes } = await import("../src/agent-routes.js"); +export const { createHealthEventStore } = await import("../src/health-store.js"); +export const { filterRecordingJobsForExport, recordingJobsCsv } = + await import("../src/recording-job-export.js"); +export const { createRecordingJob, failRecordingJob } = await import("../src/recording-jobs.js"); +export const { registerRecordingRoutes } = await import("../src/recording-routes.js"); + +test.after(async () => { + await rm(routeRoot, { force: true, recursive: true }); +}); + +export interface PermissionCall { + action: string; + permission: Permission; + target?: AuditTarget; +} + +export interface RecordingJobActionsResponse { + data: { + actions: Record< + string, + { enabled: boolean; href?: string; payload?: Record; reason?: string } + >; + job: RecordingJob; + recording?: RecordingSummary; + retryConflict?: RecordingJob; + }; +} + +export function recordingApp({ + auditStore, + permissionCalls, + permissions, + recordingStore, + scopedRecordingSnapshots, + visibleRecordingIds, +}: { + auditStore: ReturnType; + permissionCalls: PermissionCall[]; + permissions?: Permission[]; + recordingStore: RecordingStore; + scopedRecordingSnapshots?: RecordingSummary[]; + visibleRecordingIds?: string[]; +}) { + const app = new Hono(); + const currentUser = user(permissions); + + registerRecordingRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: memoryNodeStore(), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + requirePermission: requirePermission(permissionCalls), + scopedNodes: async () => [], + scopedRecordings: async () => { + const recordings = scopedRecordingSnapshots ?? (await recordingStore.list()); + + return visibleRecordingIds + ? recordings.filter((recording) => visibleRecordingIds.includes(recording.id)) + : recordings; + }, + settingsStore: memorySettingsStore(), + }); + + return app; +} + +export function requirePermission(calls: PermissionCall[]): RequirePermission { + return (permission, action, target) => async (c, next) => { + calls.push({ + action, + permission, + target: target ? await target(c) : undefined, + }); + await next(); + }; +} + +export function recordAuditEvent( + auditStore: ReturnType, +): RecordAuditEvent { + return async (_c, input) => { + const event: AuditEvent = { + action: input.action, + actor: { + id: "user_recording_job_export", + name: "Recording Job Export User", + roles: ["operator"], + type: "user", + }, + actorContext: {}, + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: input.details ?? {}, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; +} + +export function memoryNodeStore(): NodeStore { + const node: RecorderNode = { + agentVersion: "0.1.0", + alias: "Room Recorder", + hostname: "room-recorder", + id: "node_1", + interfaces: [], + ipAddresses: ["10.1.2.3"], + lastSeenAt: "2026-06-20T12:00:00.000Z", + location: { room: "Room", site: "Main" }, + status: "online", + tags: [], + }; + + return { + async authenticateCredential() { + return undefined; + }, + async enroll() { + throw new Error("not implemented"); + }, + async find(nodeId) { + return nodeId === node.id ? node : undefined; + }, + async heartbeat() { + throw new Error("not implemented"); + }, + async list() { + return [node]; + }, + async rotateCredential() { + throw new Error("not implemented"); + }, + async update() { + throw new Error("not implemented"); + }, + async updateInterface() { + throw new Error("not implemented"); + }, + }; +} + +export function memoryMeterFrameStore(): MeterFrameStore { + return { + async history(): Promise { + return []; + }, + async latest() { + return undefined; + }, + async save(frame) { + return { + frame, + receivedAt: new Date().toISOString(), + }; + }, + }; +} + +export function memorySettingsStore(): SettingsStore { + return { + async findRecordingProfile(profileId) { + return profileId === defaultVoiceRecordingProfile.id + ? defaultVoiceRecordingProfile + : undefined; + }, + async listChannelMapAssignments() { + return []; + }, + async listRecordingProfiles(): Promise { + return [defaultVoiceRecordingProfile]; + }, + } as SettingsStore; +} + +export function user(permissions: Permission[] = ["recording:read"]): CurrentUser { + return { + email: "recording-job-export@example.com", + groups: [], + id: "user_recording_job_export", + name: "Recording Job Export User", + permissions, + provider: "local", + resourceGrants: [], + roles: ["operator"], + }; +} + +export function job(input: Partial = {}): RecordingJob { + return { + command: { + captureChannels: 2, + captureDevice: "hw:0,0", + captureFormat: "S16_LE", + captureSampleRate: 48000, + durationSeconds: 3600, + outputCodec: "wav", + outputFileName: "recording.wav", + type: "alsa_capture", + }, + createdAt: "2026-06-20T12:00:00.000Z", + id: "job_1", + nodeId: "node_1", + recordingId: "rec_1", + status: "queued", + ...input, + }; +} + +export function recordingSummary(input: Partial = {}): RecordingSummary { + return { + cached: false, + durationSeconds: 0, + folder: "tests", + healthStatus: "unknown", + id: "rec_1", + name: "Retry Recording", + nodeId: "node_1", + recordedAt: "2026-06-20T12:00:00.000Z", + source: "ad_hoc", + status: "recording", + tags: ["voice"], + ...input, + }; +} diff --git a/apps/api/test/recording-job-export-lifecycle.test.ts b/apps/api/test/recording-job-export-lifecycle.test.ts new file mode 100644 index 0000000..ca2a011 --- /dev/null +++ b/apps/api/test/recording-job-export-lifecycle.test.ts @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import test from "node:test"; +import type { RecordingJob, RecordingSummary } from "@rakkr/shared"; +import { + createAuditStore, + createRecordingJob, + failRecordingJob, + memoryRecordingStore, + recordingApp, + recordingSummary, + routeRoot, + type PermissionCall, +} from "./recording-job-export-harness.js"; + +test("recording job retry route is RBAC-gated audited and resets recording state", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const recording = recordingSummary({ + cachePath: path.join(routeRoot, "stale.wav"), + cached: true, + checksum: "stale-checksum", + durationSeconds: 12, + healthStatus: "critical", + id: `rec_retry_${randomUUID()}`, + status: "failed", + }); + const recordingStore = memoryRecordingStore([recording]); + const sourceJob = await createRecordingJob(recording, { + captureDevice: "hw:Retry,0", + }); + + await failRecordingJob(sourceJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + }); + const response = await app.request(`/api/v1/recording-jobs/${sourceJob.id}/retry`, { + method: "POST", + }); + const body = (await response.json()) as { data: RecordingJob }; + const updatedRecording = await recordingStore.find(recording.id); + const [event] = await auditStore.list({ action: "recording_jobs.retry.succeeded" }); + + assert.equal(response.status, 201); + assert.equal(permissionCalls.at(-1)?.permission, "recording:control"); + assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.retry"); + assert.deepEqual(permissionCalls.at(-1)?.target, { + id: recording.id, + type: "recording", + }); + assert.notEqual(body.data.id, sourceJob.id); + assert.equal(body.data.status, "queued"); + assert.equal(body.data.recordingId, recording.id); + assert.equal(body.data.command.captureDevice, "hw:Retry,0"); + assert.equal(updatedRecording?.status, "recording"); + assert.equal(updatedRecording?.cached, false); + assert.equal(updatedRecording?.cachePath, undefined); + assert.equal(updatedRecording?.checksum, undefined); + assert.equal(updatedRecording?.durationSeconds, 0); + assert.equal(updatedRecording?.healthStatus, "unknown"); + assert.equal(event?.permission, "recording:control"); + assert.equal(event?.target.id, recording.id); + assert.equal(event?.before?.jobId, sourceJob.id); + assert.equal(event?.after?.jobId, body.data.id); + assert.equal(event?.correlationIds?.sourceJobId, sourceJob.id); + assert.equal(event?.correlationIds?.retryJobId, body.data.id); +}); + +test("recording job bulk retry route retries visible terminal jobs and audits collection", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const recordings = [ + recordingSummary({ + cached: true, + checksum: "old-a", + durationSeconds: 60, + healthStatus: "critical", + id: `rec_bulk_retry_a_${randomUUID()}`, + status: "failed", + }), + recordingSummary({ + cached: true, + checksum: "old-b", + durationSeconds: 30, + healthStatus: "warning", + id: `rec_bulk_retry_b_${randomUUID()}`, + status: "cancelled", + }), + ]; + const recordingStore = memoryRecordingStore(recordings); + const firstJob = await createRecordingJob(recordings[0] as RecordingSummary); + const secondJob = await createRecordingJob(recordings[1] as RecordingSummary); + + await failRecordingJob(firstJob.id, "capture_failed"); + await failRecordingJob(secondJob.id, "operator_cancelled"); + + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + }); + const response = await app.request("/api/v1/recording-jobs/bulk-retry", { + body: JSON.stringify({ jobIds: [firstJob.id, secondJob.id, firstJob.id] }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const body = (await response.json()) as { + data: RecordingJob[]; + meta: { retriedCount: number }; + }; + const [event] = await auditStore.list({ action: "recording_jobs.bulk_retry.succeeded" }); + + assert.equal(response.status, 201); + assert.equal(permissionCalls.at(-1)?.permission, "recording:control"); + assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.bulk_retry"); + assert.deepEqual(permissionCalls.at(-1)?.target, { + id: "recording_job_collection", + type: "recording_collection", + }); + assert.equal(body.meta.retriedCount, 2); + assert.equal(body.data.length, 2); + assert(body.data.every((job) => job.status === "queued")); + assert.equal((await recordingStore.find(recordings[0].id))?.status, "recording"); + assert.equal((await recordingStore.find(recordings[0].id))?.cached, false); + assert.equal((await recordingStore.find(recordings[1].id))?.status, "recording"); + assert.equal(event?.permission, "recording:control"); + assert.equal(event?.target.id, "recording_job_collection"); + assert.deepEqual(event?.details.sourceJobIds, [firstJob.id, secondJob.id]); + assert.equal(event?.details.requestedCount, 3); + assert.equal(event?.details.retriedCount, 2); +}); + +test("recording job bulk stop route stops visible active jobs and audits collection", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const recordings = [ + recordingSummary({ id: `rec_bulk_stop_a_${randomUUID()}`, status: "recording" }), + recordingSummary({ id: `rec_bulk_stop_b_${randomUUID()}`, status: "recording" }), + ]; + const recordingStore = memoryRecordingStore(recordings); + const firstJob = await createRecordingJob(recordings[0] as RecordingSummary); + const secondJob = await createRecordingJob(recordings[1] as RecordingSummary); + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + }); + const response = await app.request("/api/v1/recording-jobs/bulk-stop", { + body: JSON.stringify({ jobIds: [firstJob.id, secondJob.id] }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const body = (await response.json()) as { + data: RecordingJob[]; + meta: { stoppedCount: number }; + }; + const [event] = await auditStore.list({ action: "recording_jobs.bulk_stop.succeeded" }); + + assert.equal(response.status, 200); + assert.equal(permissionCalls.at(-1)?.permission, "recording:control"); + assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.bulk_stop"); + assert.equal(body.meta.stoppedCount, 2); + assert(body.data.every((job) => job.status === "stop_requested")); + assert.equal((await recordingStore.find(recordings[0].id))?.status, "completed"); + assert.equal((await recordingStore.find(recordings[1].id))?.status, "completed"); + assert.equal(event?.permission, "recording:control"); + assert.equal(event?.target.id, "recording_job_collection"); + assert.equal(event?.details.stoppedCount, 2); +}); + +test("recording job bulk retry rejects hidden jobs before mutating", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const visible = recordingSummary({ id: `rec_bulk_visible_${randomUUID()}`, status: "failed" }); + const hidden = recordingSummary({ id: `rec_bulk_hidden_${randomUUID()}`, status: "failed" }); + const recordingStore = memoryRecordingStore([visible, hidden]); + const visibleJob = await createRecordingJob(visible); + const hiddenJob = await createRecordingJob(hidden); + + await failRecordingJob(visibleJob.id, "capture_failed"); + await failRecordingJob(hiddenJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + visibleRecordingIds: [visible.id], + }); + const response = await app.request("/api/v1/recording-jobs/bulk-retry", { + body: JSON.stringify({ jobIds: [visibleJob.id, hiddenJob.id] }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + const [event] = await auditStore.list({ action: "recording_jobs.bulk_retry.failed" }); + + assert.equal(response.status, 404); + assert.equal(event?.outcome, "denied"); + assert.equal(event?.reason, "recording_job_not_visible"); + assert.deepEqual(event?.details.hiddenIds, [hiddenJob.id]); +}); diff --git a/apps/api/test/recording-job-export-read.test.ts b/apps/api/test/recording-job-export-read.test.ts new file mode 100644 index 0000000..ac3398d --- /dev/null +++ b/apps/api/test/recording-job-export-read.test.ts @@ -0,0 +1,352 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Hono } from "hono"; +import type { RecordingJob, RecordingSummary } from "@rakkr/shared"; +import type { AppBindings } from "../src/http-types.js"; +import { + createAuditStore, + createHealthEventStore, + createRecordingJob, + failRecordingJob, + memoryMeterFrameStore, + memoryNodeStore, + memoryRecordingStore, + memorySettingsStore, + recordAuditEvent, + recordingApp, + recordingSummary, + registerAgentRoutes, + registerRecordingRoutes, + requirePermission, + user, + type PermissionCall, + type RecordingJobActionsResponse, +} from "./recording-job-export-harness.js"; + +test("recording job list route filters scoped jobs by status search node and backend", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const prefix = `filter_${randomUUID()}`; + const recordings = [ + recordingSummary({ id: `rec_${prefix}_jack`, nodeId: `${prefix}_node_jack` }), + recordingSummary({ id: `rec_${prefix}_pipewire`, nodeId: `${prefix}_node_pipewire` }), + recordingSummary({ id: `rec_${prefix}_failed`, nodeId: `${prefix}_node_failed` }), + ]; + const recordingStore = memoryRecordingStore(recordings); + const jackJob = await createRecordingJob(recordings[0] as RecordingSummary, { + captureBackend: "jack", + captureDevice: `${prefix}:system:capture_1`, + captureInterfaceId: `${prefix}_iface_jack`, + }); + + await createRecordingJob(recordings[1] as RecordingSummary, { + captureBackend: "pipewire", + captureDevice: `${prefix}:alsa_input.usb-recorder`, + captureInterfaceId: `${prefix}_iface_pipewire`, + }); + const failedJob = await createRecordingJob(recordings[2] as RecordingSummary, { + captureBackend: "jack", + captureDevice: `${prefix}:system:capture_2`, + captureInterfaceId: `${prefix}_iface_other`, + }); + + await failRecordingJob(failedJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + }); + const response = await app.request( + `/api/v1/recording-jobs?search=${prefix}&status=queued&nodeId=${prefix}_node_jack&captureBackend=jack&captureInterfaceId=${prefix}_iface_jack&createdFrom=2026-06-01T00%3A00%3A00.000Z&createdTo=2099-12-31T23%3A59%3A59.999Z`, + ); + const body = (await response.json()) as { data: RecordingJob[] }; + const invalidResponse = await app.request("/api/v1/recording-jobs?createdFrom=2026-06-20"); + const [successEvent] = await auditStore.list({ action: "recording_jobs.read.succeeded" }); + const [failedEvent] = await auditStore.list({ action: "recording_jobs.read.failed" }); + + assert.equal(response.status, 200); + assert.equal(permissionCalls.at(-1)?.permission, "recording:read"); + assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.read"); + assert.deepEqual( + body.data.map((recordingJob) => recordingJob.id), + [jackJob.id], + ); + assert.equal(invalidResponse.status, 400); + assert.equal(successEvent?.target.id, "recording_job_collection"); + assert.equal(successEvent?.details.returnedCount, 1); + assert.equal(successEvent?.details.filters.status, "queued"); + assert.equal(failedEvent?.target.id, "recording_job_collection"); + assert.equal(failedEvent?.reason, "invalid_filters"); + assert.equal(failedEvent?.details.issueCount, 1); +}); + +test("recording job detail route returns only scoped jobs", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const visible = recordingSummary({ id: `rec_detail_visible_${randomUUID()}` }); + const hidden = recordingSummary({ id: `rec_detail_hidden_${randomUUID()}` }); + const recordingStore = memoryRecordingStore([visible, hidden]); + const visibleJob = await createRecordingJob(visible); + const hiddenJob = await createRecordingJob(hidden); + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + visibleRecordingIds: [visible.id], + }); + + const visibleResponse = await app.request(`/api/v1/recording-jobs/${visibleJob.id}`); + const hiddenResponse = await app.request(`/api/v1/recording-jobs/${hiddenJob.id}`); + const missingResponse = await app.request("/api/v1/recording-jobs/job_missing_detail"); + const visibleBody = (await visibleResponse.json()) as { data: RecordingJob }; + const [successEvent] = await auditStore.list({ + action: "recording_jobs.detail.read.succeeded", + }); + const failedEvents = await auditStore.list({ action: "recording_jobs.detail.read.failed" }); + + assert.equal(visibleResponse.status, 200); + assert.equal(visibleBody.data.id, visibleJob.id); + assert.equal(visibleBody.data.recordingId, visible.id); + assert.equal(hiddenResponse.status, 404); + assert.equal(missingResponse.status, 404); + assert.deepEqual(permissionCalls.at(-3), { + action: "recording_jobs.detail.read", + permission: "recording:read", + target: { id: visible.id, type: "recording" }, + }); + assert.deepEqual(permissionCalls.at(-2), { + action: "recording_jobs.detail.read", + permission: "recording:read", + target: { id: hidden.id, type: "recording" }, + }); + assert.deepEqual(permissionCalls.at(-1), { + action: "recording_jobs.detail.read", + permission: "recording:read", + target: { id: "job_missing_detail", type: "recording_job" }, + }); + assert.equal(successEvent?.target.id, visibleJob.id); + assert.equal(successEvent?.correlationIds.recordingId, visible.id); + assert.equal(successEvent?.details.recordingId, visible.id); + assert.equal(successEvent?.details.status, visibleJob.status); + assert.deepEqual(failedEvents.map((event) => [event.target.id, event.reason]).sort(), [ + [hiddenJob.id, "recording_job_not_found"], + ["job_missing_detail", "recording_job_not_found"], + ]); +}); + +test("recording job detail route lets controller bearer reads pass agent route", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const recordingStore = memoryRecordingStore([recordingSummary()]); + const permissionCalls: PermissionCall[] = []; + const job = await createRecordingJob((await recordingStore.list())[0]!); + + registerAgentRoutes({ + app, + healthEventStore: createHealthEventStore("", []), + meterFrameStore: memoryMeterFrameStore(), + nodeStore: memoryNodeStore(), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + settingsStore: memorySettingsStore(), + }); + registerRecordingRoutes({ + app, + currentAuth: () => ({ user: user() }), + currentUser: () => user(), + nodeStore: memoryNodeStore(), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + requirePermission: requirePermission(permissionCalls), + scopedNodes: async () => [], + scopedRecordings: () => recordingStore.list(), + settingsStore: memorySettingsStore(), + }); + + const response = await app.request(`/api/v1/recording-jobs/${job.id}`, { + headers: { authorization: "Bearer user-token" }, + }); + const body = (await response.json()) as { data: RecordingJob }; + const missingTokenEvents = await auditStore.list({ + action: "recording_jobs.read_one.failed", + }); + + assert.equal(response.status, 200); + assert.equal(body.data.id, job.id); + assert.equal(body.data.recordingId, recordingSummary().id); + assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.detail.read"); + assert.equal(missingTokenEvents.length, 0); +}); + +test("recording job action summary returns readiness links and payloads", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const recording = recordingSummary({ id: `rec_job_actions_${randomUUID()}` }); + const recordingStore = memoryRecordingStore([recording]); + const failedJob = await createRecordingJob(recording); + + await failRecordingJob(failedJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls, + permissions: ["recording:control", "recording:read"], + recordingStore, + }); + + const response = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); + const body = (await response.json()) as RecordingJobActionsResponse; + const [auditEvent] = await auditStore.list({ + action: "recording_jobs.actions.read.succeeded", + }); + + assert.equal(response.status, 200); + assert.deepEqual(permissionCalls.at(-1), { + action: "recording_jobs.actions.read", + permission: "recording:read", + target: { id: recording.id, type: "recording" }, + }); + assert.equal(body.data.job.id, failedJob.id); + assert.equal(body.data.recording?.id, recording.id); + assert.equal(body.data.retryConflict, undefined); + assert.equal(body.data.actions.detail.enabled, true); + assert.equal(body.data.actions.exportSelected.enabled, true); + assert.deepEqual(body.data.actions.exportSelected.payload, { jobIds: [failedJob.id] }); + assert.equal(body.data.actions.retry.enabled, true); + assert.equal(body.data.actions.retry.href, `/api/v1/recording-jobs/${failedJob.id}/retry`); + assert.equal(body.data.actions.stop.enabled, false); + assert.equal(body.data.actions.stop.reason, "recording_job_not_stoppable"); + assert.deepEqual(body.data.actions.stop.payload, { jobIds: [failedJob.id] }); + assert.equal(auditEvent?.outcome, "succeeded"); + assert.equal(auditEvent?.permission, "recording:read"); + assert.equal(auditEvent?.target.id, failedJob.id); + assert.equal(auditEvent?.target.type, "recording_job"); + assert.equal(auditEvent?.correlationIds?.recordingId, recording.id); + assert.equal(auditEvent?.details.recordingAvailable, true); + assert.equal(auditEvent?.details.status, "failed"); + assert.equal(auditEvent?.details.visibleActionCount, 4); +}); + +test("recording job action summary uses scoped recording context for readiness", async () => { + const auditStore = createAuditStore(""); + const scopedRecording = recordingSummary({ + id: `rec_job_scoped_context_${randomUUID()}`, + name: "Scoped Recording Context", + }); + const rawRecording = recordingSummary({ + id: scopedRecording.id, + name: "Raw Recording Context", + }); + const recordingStore = memoryRecordingStore([rawRecording]); + const failedJob = await createRecordingJob(scopedRecording); + + await failRecordingJob(failedJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls: [], + permissions: ["recording:control", "recording:read"], + recordingStore, + scopedRecordingSnapshots: [scopedRecording], + }); + + const response = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); + const body = (await response.json()) as RecordingJobActionsResponse; + + assert.equal(response.status, 200); + assert.equal(body.data.job.id, failedJob.id); + assert.equal(body.data.recording?.id, scopedRecording.id); + assert.equal(body.data.recording?.name, "Scoped Recording Context"); + assert.equal(body.data.actions.retry.enabled, true); + assert.equal(body.data.actions.stop.enabled, false); + assert.equal(body.data.actions.stop.reason, "recording_job_not_stoppable"); +}); + +test("recording job action summary explains lifecycle and dependency blockers", async () => { + const auditStore = createAuditStore(""); + const recording = recordingSummary({ id: `rec_job_blockers_${randomUUID()}` }); + const recordingStore = memoryRecordingStore([recording]); + const queuedJob = await createRecordingJob(recording); + const failedJob = await createRecordingJob(recording); + + await failRecordingJob(failedJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls: [], + permissions: ["recording:control", "recording:read"], + recordingStore, + }); + + const queuedResponse = await app.request(`/api/v1/recording-jobs/${queuedJob.id}/actions`); + const failedResponse = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); + const queuedBody = (await queuedResponse.json()) as RecordingJobActionsResponse; + const failedBody = (await failedResponse.json()) as RecordingJobActionsResponse; + + assert.equal(queuedResponse.status, 200); + assert.equal(queuedBody.data.actions.stop.enabled, true); + assert.equal(queuedBody.data.actions.retry.enabled, false); + assert.equal(queuedBody.data.actions.retry.reason, "recording_job_not_retryable"); + assert.equal(failedResponse.status, 200); + assert.equal(failedBody.data.actions.retry.enabled, false); + assert.equal(failedBody.data.actions.retry.reason, "active_job_exists"); + assert.equal(failedBody.data.retryConflict?.id, queuedJob.id); +}); + +test("recording job action summary reports missing permission before lifecycle readiness", async () => { + const auditStore = createAuditStore(""); + const recording = recordingSummary({ id: `rec_job_permission_${randomUUID()}` }); + const recordingStore = memoryRecordingStore([recording]); + const failedJob = await createRecordingJob(recording); + + await failRecordingJob(failedJob.id, "capture_failed"); + + const app = recordingApp({ + auditStore, + permissionCalls: [], + permissions: ["recording:read"], + recordingStore, + }); + + const response = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); + const body = (await response.json()) as RecordingJobActionsResponse; + + assert.equal(response.status, 200); + assert.equal(body.data.actions.detail.enabled, true); + assert.equal(body.data.actions.retry.enabled, false); + assert.equal(body.data.actions.retry.reason, "missing_permission"); + assert.equal(body.data.actions.stop.enabled, false); + assert.equal(body.data.actions.stop.reason, "missing_permission"); +}); + +test("recording job action summary hides jobs outside scoped visibility", async () => { + const auditStore = createAuditStore(""); + const permissionCalls: PermissionCall[] = []; + const visible = recordingSummary({ id: `rec_job_action_visible_${randomUUID()}` }); + const hidden = recordingSummary({ id: `rec_job_action_hidden_${randomUUID()}` }); + const recordingStore = memoryRecordingStore([visible, hidden]); + const hiddenJob = await createRecordingJob(hidden); + const app = recordingApp({ + auditStore, + permissionCalls, + recordingStore, + visibleRecordingIds: [visible.id], + }); + + const response = await app.request(`/api/v1/recording-jobs/${hiddenJob.id}/actions`); + const [auditEvent] = await auditStore.list({ action: "recording_jobs.actions.read.failed" }); + + assert.equal(response.status, 404); + assert.deepEqual(permissionCalls.at(-1), { + action: "recording_jobs.actions.read", + permission: "recording:read", + target: { id: hidden.id, type: "recording" }, + }); + assert.equal(auditEvent?.outcome, "failed"); + assert.equal(auditEvent?.permission, "recording:read"); + assert.equal(auditEvent?.reason, "recording_job_not_found"); + assert.equal(auditEvent?.target.id, hiddenJob.id); + assert.equal(auditEvent?.target.type, "recording_job"); +}); diff --git a/apps/api/test/recording-job-export.test.ts b/apps/api/test/recording-job-export.test.ts index f594ed2..d126306 100644 --- a/apps/api/test/recording-job-export.test.ts +++ b/apps/api/test/recording-job-export.test.ts @@ -1,49 +1,18 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import test from "node:test"; -import { Hono } from "hono"; -import type { - AuditEvent, - CurrentUser, - Permission, - RecorderNode, - MeterFrame, - RecordingJob, - RecordingProfile, - RecordingSummary, -} from "@rakkr/shared"; -import { defaultVoiceRecordingProfile } from "@rakkr/shared"; -import type { - AppBindings, - AuditTarget, - RecordAuditEvent, - RequirePermission, -} from "../src/http-types.js"; -import type { MeterFrameStore } from "../src/meter-store.js"; -import type { NodeStore } from "../src/node-store.js"; -import type { RecordingStore } from "../src/recording-store.js"; -import type { SettingsStore } from "../src/settings-store.js"; -import { memoryRecordingStore } from "./recording-store-mock.js"; - -const routeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-recording-job-export-")); -process.env.DATABASE_URL = ""; -process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(routeRoot, "jobs.json"); -process.env.RAKKR_RETENTION_POLICY_STORE_PATH = path.join(routeRoot, "retention-policies.json"); - -const { createAuditStore } = await import("../src/audit-store.js"); -const { registerAgentRoutes } = await import("../src/agent-routes.js"); -const { createHealthEventStore } = await import("../src/health-store.js"); -const { filterRecordingJobsForExport, recordingJobsCsv } = - await import("../src/recording-job-export.js"); -const { createRecordingJob, failRecordingJob } = await import("../src/recording-jobs.js"); -const { registerRecordingRoutes } = await import("../src/recording-routes.js"); - -test.after(async () => { - await rm(routeRoot, { force: true, recursive: true }); -}); +import type { RecordingSummary } from "@rakkr/shared"; +import { + createAuditStore, + createRecordingJob, + filterRecordingJobsForExport, + job, + memoryRecordingStore, + recordingApp, + recordingJobsCsv, + recordingSummary, + type PermissionCall, +} from "./recording-job-export-harness.js"; test("recording job export helpers filter and render csv", () => { const visibleJob = job({ @@ -89,333 +58,6 @@ test("recording job export helpers filter and render csv", () => { assert.doesNotMatch(csv, /job_other/); }); -test("recording job list route filters scoped jobs by status search node and backend", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const prefix = `filter_${randomUUID()}`; - const recordings = [ - recordingSummary({ id: `rec_${prefix}_jack`, nodeId: `${prefix}_node_jack` }), - recordingSummary({ id: `rec_${prefix}_pipewire`, nodeId: `${prefix}_node_pipewire` }), - recordingSummary({ id: `rec_${prefix}_failed`, nodeId: `${prefix}_node_failed` }), - ]; - const recordingStore = memoryRecordingStore(recordings); - const jackJob = await createRecordingJob(recordings[0] as RecordingSummary, { - captureBackend: "jack", - captureDevice: `${prefix}:system:capture_1`, - captureInterfaceId: `${prefix}_iface_jack`, - }); - - await createRecordingJob(recordings[1] as RecordingSummary, { - captureBackend: "pipewire", - captureDevice: `${prefix}:alsa_input.usb-recorder`, - captureInterfaceId: `${prefix}_iface_pipewire`, - }); - const failedJob = await createRecordingJob(recordings[2] as RecordingSummary, { - captureBackend: "jack", - captureDevice: `${prefix}:system:capture_2`, - captureInterfaceId: `${prefix}_iface_other`, - }); - - await failRecordingJob(failedJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - }); - const response = await app.request( - `/api/v1/recording-jobs?search=${prefix}&status=queued&nodeId=${prefix}_node_jack&captureBackend=jack&captureInterfaceId=${prefix}_iface_jack&createdFrom=2026-06-01T00%3A00%3A00.000Z&createdTo=2099-12-31T23%3A59%3A59.999Z`, - ); - const body = (await response.json()) as { data: RecordingJob[] }; - const invalidResponse = await app.request("/api/v1/recording-jobs?createdFrom=2026-06-20"); - const [successEvent] = await auditStore.list({ action: "recording_jobs.read.succeeded" }); - const [failedEvent] = await auditStore.list({ action: "recording_jobs.read.failed" }); - - assert.equal(response.status, 200); - assert.equal(permissionCalls.at(-1)?.permission, "recording:read"); - assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.read"); - assert.deepEqual( - body.data.map((recordingJob) => recordingJob.id), - [jackJob.id], - ); - assert.equal(invalidResponse.status, 400); - assert.equal(successEvent?.target.id, "recording_job_collection"); - assert.equal(successEvent?.details.returnedCount, 1); - assert.equal(successEvent?.details.filters.status, "queued"); - assert.equal(failedEvent?.target.id, "recording_job_collection"); - assert.equal(failedEvent?.reason, "invalid_filters"); - assert.equal(failedEvent?.details.issueCount, 1); -}); - -test("recording job detail route returns only scoped jobs", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const visible = recordingSummary({ id: `rec_detail_visible_${randomUUID()}` }); - const hidden = recordingSummary({ id: `rec_detail_hidden_${randomUUID()}` }); - const recordingStore = memoryRecordingStore([visible, hidden]); - const visibleJob = await createRecordingJob(visible); - const hiddenJob = await createRecordingJob(hidden); - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - visibleRecordingIds: [visible.id], - }); - - const visibleResponse = await app.request(`/api/v1/recording-jobs/${visibleJob.id}`); - const hiddenResponse = await app.request(`/api/v1/recording-jobs/${hiddenJob.id}`); - const missingResponse = await app.request("/api/v1/recording-jobs/job_missing_detail"); - const visibleBody = (await visibleResponse.json()) as { data: RecordingJob }; - const [successEvent] = await auditStore.list({ - action: "recording_jobs.detail.read.succeeded", - }); - const failedEvents = await auditStore.list({ action: "recording_jobs.detail.read.failed" }); - - assert.equal(visibleResponse.status, 200); - assert.equal(visibleBody.data.id, visibleJob.id); - assert.equal(visibleBody.data.recordingId, visible.id); - assert.equal(hiddenResponse.status, 404); - assert.equal(missingResponse.status, 404); - assert.deepEqual(permissionCalls.at(-3), { - action: "recording_jobs.detail.read", - permission: "recording:read", - target: { id: visible.id, type: "recording" }, - }); - assert.deepEqual(permissionCalls.at(-2), { - action: "recording_jobs.detail.read", - permission: "recording:read", - target: { id: hidden.id, type: "recording" }, - }); - assert.deepEqual(permissionCalls.at(-1), { - action: "recording_jobs.detail.read", - permission: "recording:read", - target: { id: "job_missing_detail", type: "recording_job" }, - }); - assert.equal(successEvent?.target.id, visibleJob.id); - assert.equal(successEvent?.correlationIds.recordingId, visible.id); - assert.equal(successEvent?.details.recordingId, visible.id); - assert.equal(successEvent?.details.status, visibleJob.status); - assert.deepEqual(failedEvents.map((event) => [event.target.id, event.reason]).sort(), [ - [hiddenJob.id, "recording_job_not_found"], - ["job_missing_detail", "recording_job_not_found"], - ]); -}); - -test("recording job detail route lets controller bearer reads pass agent route", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const recordingStore = memoryRecordingStore([recordingSummary()]); - const permissionCalls: PermissionCall[] = []; - const job = await createRecordingJob((await recordingStore.list())[0]!); - - registerAgentRoutes({ - app, - healthEventStore: createHealthEventStore("", []), - meterFrameStore: memoryMeterFrameStore(), - nodeStore: memoryNodeStore(), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - settingsStore: memorySettingsStore(), - }); - registerRecordingRoutes({ - app, - currentAuth: () => ({ user: user() }), - currentUser: () => user(), - nodeStore: memoryNodeStore(), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - requirePermission: requirePermission(permissionCalls), - scopedNodes: async () => [], - scopedRecordings: () => recordingStore.list(), - settingsStore: memorySettingsStore(), - }); - - const response = await app.request(`/api/v1/recording-jobs/${job.id}`, { - headers: { authorization: "Bearer user-token" }, - }); - const body = (await response.json()) as { data: RecordingJob }; - const missingTokenEvents = await auditStore.list({ - action: "recording_jobs.read_one.failed", - }); - - assert.equal(response.status, 200); - assert.equal(body.data.id, job.id); - assert.equal(body.data.recordingId, recordingSummary().id); - assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.detail.read"); - assert.equal(missingTokenEvents.length, 0); -}); - -test("recording job action summary returns readiness links and payloads", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const recording = recordingSummary({ id: `rec_job_actions_${randomUUID()}` }); - const recordingStore = memoryRecordingStore([recording]); - const failedJob = await createRecordingJob(recording); - - await failRecordingJob(failedJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls, - permissions: ["recording:control", "recording:read"], - recordingStore, - }); - - const response = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); - const body = (await response.json()) as RecordingJobActionsResponse; - const [auditEvent] = await auditStore.list({ - action: "recording_jobs.actions.read.succeeded", - }); - - assert.equal(response.status, 200); - assert.deepEqual(permissionCalls.at(-1), { - action: "recording_jobs.actions.read", - permission: "recording:read", - target: { id: recording.id, type: "recording" }, - }); - assert.equal(body.data.job.id, failedJob.id); - assert.equal(body.data.recording?.id, recording.id); - assert.equal(body.data.retryConflict, undefined); - assert.equal(body.data.actions.detail.enabled, true); - assert.equal(body.data.actions.exportSelected.enabled, true); - assert.deepEqual(body.data.actions.exportSelected.payload, { jobIds: [failedJob.id] }); - assert.equal(body.data.actions.retry.enabled, true); - assert.equal(body.data.actions.retry.href, `/api/v1/recording-jobs/${failedJob.id}/retry`); - assert.equal(body.data.actions.stop.enabled, false); - assert.equal(body.data.actions.stop.reason, "recording_job_not_stoppable"); - assert.deepEqual(body.data.actions.stop.payload, { jobIds: [failedJob.id] }); - assert.equal(auditEvent?.outcome, "succeeded"); - assert.equal(auditEvent?.permission, "recording:read"); - assert.equal(auditEvent?.target.id, failedJob.id); - assert.equal(auditEvent?.target.type, "recording_job"); - assert.equal(auditEvent?.correlationIds?.recordingId, recording.id); - assert.equal(auditEvent?.details.recordingAvailable, true); - assert.equal(auditEvent?.details.status, "failed"); - assert.equal(auditEvent?.details.visibleActionCount, 4); -}); - -test("recording job action summary uses scoped recording context for readiness", async () => { - const auditStore = createAuditStore(""); - const scopedRecording = recordingSummary({ - id: `rec_job_scoped_context_${randomUUID()}`, - name: "Scoped Recording Context", - }); - const rawRecording = recordingSummary({ - id: scopedRecording.id, - name: "Raw Recording Context", - }); - const recordingStore = memoryRecordingStore([rawRecording]); - const failedJob = await createRecordingJob(scopedRecording); - - await failRecordingJob(failedJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls: [], - permissions: ["recording:control", "recording:read"], - recordingStore, - scopedRecordingSnapshots: [scopedRecording], - }); - - const response = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); - const body = (await response.json()) as RecordingJobActionsResponse; - - assert.equal(response.status, 200); - assert.equal(body.data.job.id, failedJob.id); - assert.equal(body.data.recording?.id, scopedRecording.id); - assert.equal(body.data.recording?.name, "Scoped Recording Context"); - assert.equal(body.data.actions.retry.enabled, true); - assert.equal(body.data.actions.stop.enabled, false); - assert.equal(body.data.actions.stop.reason, "recording_job_not_stoppable"); -}); - -test("recording job action summary explains lifecycle and dependency blockers", async () => { - const auditStore = createAuditStore(""); - const recording = recordingSummary({ id: `rec_job_blockers_${randomUUID()}` }); - const recordingStore = memoryRecordingStore([recording]); - const queuedJob = await createRecordingJob(recording); - const failedJob = await createRecordingJob(recording); - - await failRecordingJob(failedJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls: [], - permissions: ["recording:control", "recording:read"], - recordingStore, - }); - - const queuedResponse = await app.request(`/api/v1/recording-jobs/${queuedJob.id}/actions`); - const failedResponse = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); - const queuedBody = (await queuedResponse.json()) as RecordingJobActionsResponse; - const failedBody = (await failedResponse.json()) as RecordingJobActionsResponse; - - assert.equal(queuedResponse.status, 200); - assert.equal(queuedBody.data.actions.stop.enabled, true); - assert.equal(queuedBody.data.actions.retry.enabled, false); - assert.equal(queuedBody.data.actions.retry.reason, "recording_job_not_retryable"); - assert.equal(failedResponse.status, 200); - assert.equal(failedBody.data.actions.retry.enabled, false); - assert.equal(failedBody.data.actions.retry.reason, "active_job_exists"); - assert.equal(failedBody.data.retryConflict?.id, queuedJob.id); -}); - -test("recording job action summary reports missing permission before lifecycle readiness", async () => { - const auditStore = createAuditStore(""); - const recording = recordingSummary({ id: `rec_job_permission_${randomUUID()}` }); - const recordingStore = memoryRecordingStore([recording]); - const failedJob = await createRecordingJob(recording); - - await failRecordingJob(failedJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls: [], - permissions: ["recording:read"], - recordingStore, - }); - - const response = await app.request(`/api/v1/recording-jobs/${failedJob.id}/actions`); - const body = (await response.json()) as RecordingJobActionsResponse; - - assert.equal(response.status, 200); - assert.equal(body.data.actions.detail.enabled, true); - assert.equal(body.data.actions.retry.enabled, false); - assert.equal(body.data.actions.retry.reason, "missing_permission"); - assert.equal(body.data.actions.stop.enabled, false); - assert.equal(body.data.actions.stop.reason, "missing_permission"); -}); - -test("recording job action summary hides jobs outside scoped visibility", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const visible = recordingSummary({ id: `rec_job_action_visible_${randomUUID()}` }); - const hidden = recordingSummary({ id: `rec_job_action_hidden_${randomUUID()}` }); - const recordingStore = memoryRecordingStore([visible, hidden]); - const hiddenJob = await createRecordingJob(hidden); - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - visibleRecordingIds: [visible.id], - }); - - const response = await app.request(`/api/v1/recording-jobs/${hiddenJob.id}/actions`); - const [auditEvent] = await auditStore.list({ action: "recording_jobs.actions.read.failed" }); - - assert.equal(response.status, 404); - assert.deepEqual(permissionCalls.at(-1), { - action: "recording_jobs.actions.read", - permission: "recording:read", - target: { id: hidden.id, type: "recording" }, - }); - assert.equal(auditEvent?.outcome, "failed"); - assert.equal(auditEvent?.permission, "recording:read"); - assert.equal(auditEvent?.reason, "recording_job_not_found"); - assert.equal(auditEvent?.target.id, hiddenJob.id); - assert.equal(auditEvent?.target.type, "recording_job"); -}); - test("recording job export route is RBAC-gated and audited", async () => { const auditStore = createAuditStore(""); const permissionCalls: PermissionCall[] = []; @@ -537,195 +179,6 @@ test("recording job selected export rejects hidden jobs before exporting", async assert.deepEqual(event?.details.jobIds, [visibleJob.id, hiddenJob.id]); }); -test("recording job retry route is RBAC-gated audited and resets recording state", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const recording = recordingSummary({ - cachePath: path.join(routeRoot, "stale.wav"), - cached: true, - checksum: "stale-checksum", - durationSeconds: 12, - healthStatus: "critical", - id: `rec_retry_${randomUUID()}`, - status: "failed", - }); - const recordingStore = memoryRecordingStore([recording]); - const sourceJob = await createRecordingJob(recording, { - captureDevice: "hw:Retry,0", - }); - - await failRecordingJob(sourceJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - }); - const response = await app.request(`/api/v1/recording-jobs/${sourceJob.id}/retry`, { - method: "POST", - }); - const body = (await response.json()) as { data: RecordingJob }; - const updatedRecording = await recordingStore.find(recording.id); - const [event] = await auditStore.list({ action: "recording_jobs.retry.succeeded" }); - - assert.equal(response.status, 201); - assert.equal(permissionCalls.at(-1)?.permission, "recording:control"); - assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.retry"); - assert.deepEqual(permissionCalls.at(-1)?.target, { - id: recording.id, - type: "recording", - }); - assert.notEqual(body.data.id, sourceJob.id); - assert.equal(body.data.status, "queued"); - assert.equal(body.data.recordingId, recording.id); - assert.equal(body.data.command.captureDevice, "hw:Retry,0"); - assert.equal(updatedRecording?.status, "recording"); - assert.equal(updatedRecording?.cached, false); - assert.equal(updatedRecording?.cachePath, undefined); - assert.equal(updatedRecording?.checksum, undefined); - assert.equal(updatedRecording?.durationSeconds, 0); - assert.equal(updatedRecording?.healthStatus, "unknown"); - assert.equal(event?.permission, "recording:control"); - assert.equal(event?.target.id, recording.id); - assert.equal(event?.before?.jobId, sourceJob.id); - assert.equal(event?.after?.jobId, body.data.id); - assert.equal(event?.correlationIds?.sourceJobId, sourceJob.id); - assert.equal(event?.correlationIds?.retryJobId, body.data.id); -}); - -test("recording job bulk retry route retries visible terminal jobs and audits collection", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const recordings = [ - recordingSummary({ - cached: true, - checksum: "old-a", - durationSeconds: 60, - healthStatus: "critical", - id: `rec_bulk_retry_a_${randomUUID()}`, - status: "failed", - }), - recordingSummary({ - cached: true, - checksum: "old-b", - durationSeconds: 30, - healthStatus: "warning", - id: `rec_bulk_retry_b_${randomUUID()}`, - status: "cancelled", - }), - ]; - const recordingStore = memoryRecordingStore(recordings); - const firstJob = await createRecordingJob(recordings[0] as RecordingSummary); - const secondJob = await createRecordingJob(recordings[1] as RecordingSummary); - - await failRecordingJob(firstJob.id, "capture_failed"); - await failRecordingJob(secondJob.id, "operator_cancelled"); - - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - }); - const response = await app.request("/api/v1/recording-jobs/bulk-retry", { - body: JSON.stringify({ jobIds: [firstJob.id, secondJob.id, firstJob.id] }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - const body = (await response.json()) as { - data: RecordingJob[]; - meta: { retriedCount: number }; - }; - const [event] = await auditStore.list({ action: "recording_jobs.bulk_retry.succeeded" }); - - assert.equal(response.status, 201); - assert.equal(permissionCalls.at(-1)?.permission, "recording:control"); - assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.bulk_retry"); - assert.deepEqual(permissionCalls.at(-1)?.target, { - id: "recording_job_collection", - type: "recording_collection", - }); - assert.equal(body.meta.retriedCount, 2); - assert.equal(body.data.length, 2); - assert(body.data.every((job) => job.status === "queued")); - assert.equal((await recordingStore.find(recordings[0].id))?.status, "recording"); - assert.equal((await recordingStore.find(recordings[0].id))?.cached, false); - assert.equal((await recordingStore.find(recordings[1].id))?.status, "recording"); - assert.equal(event?.permission, "recording:control"); - assert.equal(event?.target.id, "recording_job_collection"); - assert.deepEqual(event?.details.sourceJobIds, [firstJob.id, secondJob.id]); - assert.equal(event?.details.requestedCount, 3); - assert.equal(event?.details.retriedCount, 2); -}); - -test("recording job bulk stop route stops visible active jobs and audits collection", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const recordings = [ - recordingSummary({ id: `rec_bulk_stop_a_${randomUUID()}`, status: "recording" }), - recordingSummary({ id: `rec_bulk_stop_b_${randomUUID()}`, status: "recording" }), - ]; - const recordingStore = memoryRecordingStore(recordings); - const firstJob = await createRecordingJob(recordings[0] as RecordingSummary); - const secondJob = await createRecordingJob(recordings[1] as RecordingSummary); - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - }); - const response = await app.request("/api/v1/recording-jobs/bulk-stop", { - body: JSON.stringify({ jobIds: [firstJob.id, secondJob.id] }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - const body = (await response.json()) as { - data: RecordingJob[]; - meta: { stoppedCount: number }; - }; - const [event] = await auditStore.list({ action: "recording_jobs.bulk_stop.succeeded" }); - - assert.equal(response.status, 200); - assert.equal(permissionCalls.at(-1)?.permission, "recording:control"); - assert.equal(permissionCalls.at(-1)?.action, "recording_jobs.bulk_stop"); - assert.equal(body.meta.stoppedCount, 2); - assert(body.data.every((job) => job.status === "stop_requested")); - assert.equal((await recordingStore.find(recordings[0].id))?.status, "completed"); - assert.equal((await recordingStore.find(recordings[1].id))?.status, "completed"); - assert.equal(event?.permission, "recording:control"); - assert.equal(event?.target.id, "recording_job_collection"); - assert.equal(event?.details.stoppedCount, 2); -}); - -test("recording job bulk retry rejects hidden jobs before mutating", async () => { - const auditStore = createAuditStore(""); - const permissionCalls: PermissionCall[] = []; - const visible = recordingSummary({ id: `rec_bulk_visible_${randomUUID()}`, status: "failed" }); - const hidden = recordingSummary({ id: `rec_bulk_hidden_${randomUUID()}`, status: "failed" }); - const recordingStore = memoryRecordingStore([visible, hidden]); - const visibleJob = await createRecordingJob(visible); - const hiddenJob = await createRecordingJob(hidden); - - await failRecordingJob(visibleJob.id, "capture_failed"); - await failRecordingJob(hiddenJob.id, "capture_failed"); - - const app = recordingApp({ - auditStore, - permissionCalls, - recordingStore, - visibleRecordingIds: [visible.id], - }); - const response = await app.request("/api/v1/recording-jobs/bulk-retry", { - body: JSON.stringify({ jobIds: [visibleJob.id, hiddenJob.id] }), - headers: { "Content-Type": "application/json" }, - method: "POST", - }); - const [event] = await auditStore.list({ action: "recording_jobs.bulk_retry.failed" }); - - assert.equal(response.status, 404); - assert.equal(event?.outcome, "denied"); - assert.equal(event?.reason, "recording_job_not_visible"); - assert.deepEqual(event?.details.hiddenIds, [hiddenJob.id]); -}); - test("recording job export search matches capture backend", () => { const filtered = filterRecordingJobsForExport( [ @@ -748,227 +201,3 @@ test("recording job export search matches capture backend", () => { ["job_pipewire"], ); }); - -interface PermissionCall { - action: string; - permission: Permission; - target?: AuditTarget; -} - -interface RecordingJobActionsResponse { - data: { - actions: Record< - string, - { enabled: boolean; href?: string; payload?: Record; reason?: string } - >; - job: RecordingJob; - recording?: RecordingSummary; - retryConflict?: RecordingJob; - }; -} - -function recordingApp({ - auditStore, - permissionCalls, - permissions, - recordingStore, - scopedRecordingSnapshots, - visibleRecordingIds, -}: { - auditStore: ReturnType; - permissionCalls: PermissionCall[]; - permissions?: Permission[]; - recordingStore: RecordingStore; - scopedRecordingSnapshots?: RecordingSummary[]; - visibleRecordingIds?: string[]; -}) { - const app = new Hono(); - const currentUser = user(permissions); - - registerRecordingRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: memoryNodeStore(), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - requirePermission: requirePermission(permissionCalls), - scopedNodes: async () => [], - scopedRecordings: async () => { - const recordings = scopedRecordingSnapshots ?? (await recordingStore.list()); - - return visibleRecordingIds - ? recordings.filter((recording) => visibleRecordingIds.includes(recording.id)) - : recordings; - }, - settingsStore: memorySettingsStore(), - }); - - return app; -} - -function requirePermission(calls: PermissionCall[]): RequirePermission { - return (permission, action, target) => async (c, next) => { - calls.push({ - action, - permission, - target: target ? await target(c) : undefined, - }); - await next(); - }; -} - -function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { - return async (_c, input) => { - const event: AuditEvent = { - action: input.action, - actor: { - id: "user_recording_job_export", - name: "Recording Job Export User", - roles: ["operator"], - type: "user", - }, - actorContext: {}, - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: input.details ?? {}, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; - }; -} - -function memoryNodeStore(): NodeStore { - const node: RecorderNode = { - agentVersion: "0.1.0", - alias: "Room Recorder", - hostname: "room-recorder", - id: "node_1", - interfaces: [], - ipAddresses: ["10.1.2.3"], - lastSeenAt: "2026-06-20T12:00:00.000Z", - location: { room: "Room", site: "Main" }, - status: "online", - tags: [], - }; - - return { - async authenticateCredential() { - return undefined; - }, - async enroll() { - throw new Error("not implemented"); - }, - async find(nodeId) { - return nodeId === node.id ? node : undefined; - }, - async heartbeat() { - throw new Error("not implemented"); - }, - async list() { - return [node]; - }, - async rotateCredential() { - throw new Error("not implemented"); - }, - async update() { - throw new Error("not implemented"); - }, - async updateInterface() { - throw new Error("not implemented"); - }, - }; -} - -function memoryMeterFrameStore(): MeterFrameStore { - return { - async history(): Promise { - return []; - }, - async latest() { - return undefined; - }, - async save(frame) { - return { - frame, - receivedAt: new Date().toISOString(), - }; - }, - }; -} - -function memorySettingsStore(): SettingsStore { - return { - async findRecordingProfile(profileId) { - return profileId === defaultVoiceRecordingProfile.id - ? defaultVoiceRecordingProfile - : undefined; - }, - async listChannelMapAssignments() { - return []; - }, - async listRecordingProfiles(): Promise { - return [defaultVoiceRecordingProfile]; - }, - } as SettingsStore; -} - -function user(permissions: Permission[] = ["recording:read"]): CurrentUser { - return { - email: "recording-job-export@example.com", - groups: [], - id: "user_recording_job_export", - name: "Recording Job Export User", - permissions, - provider: "local", - resourceGrants: [], - roles: ["operator"], - }; -} - -function job(input: Partial = {}): RecordingJob { - return { - command: { - captureChannels: 2, - captureDevice: "hw:0,0", - captureFormat: "S16_LE", - captureSampleRate: 48000, - durationSeconds: 3600, - outputCodec: "wav", - outputFileName: "recording.wav", - type: "alsa_capture", - }, - createdAt: "2026-06-20T12:00:00.000Z", - id: "job_1", - nodeId: "node_1", - recordingId: "rec_1", - status: "queued", - ...input, - }; -} - -function recordingSummary(input: Partial = {}): RecordingSummary { - return { - cached: false, - durationSeconds: 0, - folder: "tests", - healthStatus: "unknown", - id: "rec_1", - name: "Retry Recording", - nodeId: "node_1", - recordedAt: "2026-06-20T12:00:00.000Z", - source: "ad_hoc", - status: "recording", - tags: ["voice"], - ...input, - }; -} diff --git a/apps/api/test/recording-routes-delete.test.ts b/apps/api/test/recording-routes-delete.test.ts new file mode 100644 index 0000000..35ab5f5 --- /dev/null +++ b/apps/api/test/recording-routes-delete.test.ts @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { defaultVoiceRecordingProfile } from "@rakkr/shared"; +import type { RecordingSummary } from "@rakkr/shared"; +import { + createAuditStore, + memoryRecordingStore, + recorderNode, + recording, + recordingApp, + routeRoot, +} from "./recording-routes-harness.js"; +import type { PermissionCall } from "./recording-routes-harness.js"; + +test("bulk recording delete removes terminal recordings and audits one snapshot", async () => { + const auditStore = createAuditStore(""); + const cachePath = "ad-hoc/rec_bulk_delete_cached.mp3"; + const cacheFilePath = path.join(routeRoot, "recording-cache", cachePath); + const recordingStore = memoryRecordingStore([ + recording({ + cached: true, + cachePath, + id: "rec_bulk_delete_cached", + status: "cached", + }), + recording({ id: "rec_bulk_delete_terminal", status: "completed" }), + recording({ id: "rec_bulk_delete_keep", status: "completed" }), + ]); + const permissionCalls: PermissionCall[] = []; + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls, + profiles: [defaultVoiceRecordingProfile], + recordingStore, + }); + + await mkdir(path.dirname(cacheFilePath), { recursive: true }); + await writeFile(cacheFilePath, Buffer.from("cached audio")); + + const response = await app.request("/api/v1/recordings/bulk-delete", { + body: JSON.stringify({ + recordingIds: [ + "rec_bulk_delete_cached", + "rec_bulk_delete_terminal", + "rec_bulk_delete_cached", + ], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const body = (await response.json()) as { + data: RecordingSummary[]; + meta: { cacheDeletedCount: number; deletedCount: number }; + }; + const deletedCached = await recordingStore.find("rec_bulk_delete_cached"); + const deletedTerminal = await recordingStore.find("rec_bulk_delete_terminal"); + const kept = await recordingStore.find("rec_bulk_delete_keep"); + const [event] = await auditStore.list({ action: "recordings.bulk_delete.succeeded" }); + const before = event?.before as { recordings?: RecordingSummary[] } | undefined; + + assert.equal(response.status, 200); + assert.equal(deletedCached, undefined); + assert.equal(deletedTerminal, undefined); + assert.equal(kept?.id, "rec_bulk_delete_keep"); + await assert.rejects( + readFile(cacheFilePath), + (error) => error instanceof Error && "code" in error && error.code === "ENOENT", + ); + assert.equal(permissionCalls.at(-1)?.permission, "recording:delete"); + assert.equal(permissionCalls.at(-1)?.action, "recordings.bulk_delete"); + assert.equal(body.meta.deletedCount, 2); + assert.equal(body.meta.cacheDeletedCount, 1); + assert.deepEqual( + body.data.map((recording) => recording.id), + ["rec_bulk_delete_cached", "rec_bulk_delete_terminal"], + ); + assert.equal(event?.permission, "recording:delete"); + assert.equal(event?.target.type, "recording_collection"); + assert.equal(event?.details.requestedCount, 3); + assert.equal(event?.details.deletedCount, 2); + assert.equal(before?.recordings?.[0]?.id, "rec_bulk_delete_cached"); +}); + +test("bulk recording delete rejects hidden recordings", async () => { + const auditStore = createAuditStore(""); + const recordingStore = memoryRecordingStore([ + recording({ id: "rec_bulk_delete_visible", status: "completed" }), + recording({ id: "rec_bulk_delete_hidden", status: "completed" }), + ]); + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls: [], + profiles: [defaultVoiceRecordingProfile], + recordingStore, + visibleRecordingIds: ["rec_bulk_delete_visible"], + }); + + const response = await app.request("/api/v1/recordings/bulk-delete", { + body: JSON.stringify({ + recordingIds: ["rec_bulk_delete_visible", "rec_bulk_delete_hidden"], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const visible = await recordingStore.find("rec_bulk_delete_visible"); + const hidden = await recordingStore.find("rec_bulk_delete_hidden"); + const [event] = await auditStore.list({ action: "recordings.bulk_delete.failed" }); + + assert.equal(response.status, 404); + assert.equal(visible?.id, "rec_bulk_delete_visible"); + assert.equal(hidden?.id, "rec_bulk_delete_hidden"); + assert.equal(event?.outcome, "denied"); + assert.equal(event?.reason, "recording_not_visible"); + assert.deepEqual(event?.details.hiddenIds, ["rec_bulk_delete_hidden"]); +}); + +test("bulk recording delete rejects active recordings before deleting anything", async () => { + const auditStore = createAuditStore(""); + const recordingStore = memoryRecordingStore([ + recording({ id: "rec_bulk_delete_ready", status: "completed" }), + recording({ id: "rec_bulk_delete_active", status: "recording" }), + ]); + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls: [], + profiles: [defaultVoiceRecordingProfile], + recordingStore, + }); + + const response = await app.request("/api/v1/recordings/bulk-delete", { + body: JSON.stringify({ + recordingIds: ["rec_bulk_delete_ready", "rec_bulk_delete_active"], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const ready = await recordingStore.find("rec_bulk_delete_ready"); + const active = await recordingStore.find("rec_bulk_delete_active"); + const [event] = await auditStore.list({ action: "recordings.bulk_delete.failed" }); + + assert.equal(response.status, 409); + assert.equal(ready?.id, "rec_bulk_delete_ready"); + assert.equal(active?.id, "rec_bulk_delete_active"); + assert.equal(event?.reason, "recording_active"); + assert.deepEqual(event?.details.activeIds, ["rec_bulk_delete_active"]); +}); + +test("recording delete removes terminal metadata cached file and audits snapshot", async () => { + const auditStore = createAuditStore(""); + const cachePath = "ad-hoc/rec_delete_cached.mp3"; + const cacheFilePath = path.join(routeRoot, "recording-cache", cachePath); + const recordingStore = memoryRecordingStore([ + recording({ + cached: true, + cachePath, + id: "rec_delete_cached", + name: "Delete Cached Recording", + status: "cached", + }), + ]); + const permissionCalls: PermissionCall[] = []; + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls, + profiles: [defaultVoiceRecordingProfile], + recordingStore, + }); + + await mkdir(path.dirname(cacheFilePath), { recursive: true }); + await writeFile(cacheFilePath, Buffer.from("cached audio")); + + const response = await app.request("/api/v1/recordings/rec_delete_cached", { + method: "DELETE", + }); + const stored = await recordingStore.find("rec_delete_cached"); + const [event] = await auditStore.list({ action: "recordings.delete.succeeded" }); + const before = event?.before as { recording?: RecordingSummary } | undefined; + + assert.equal(response.status, 204); + assert.equal(stored, undefined); + await assert.rejects( + readFile(cacheFilePath), + (error) => error instanceof Error && "code" in error && error.code === "ENOENT", + ); + assert.equal(permissionCalls.at(-1)?.permission, "recording:delete"); + assert.equal(permissionCalls.at(-1)?.action, "recordings.delete"); + assert.deepEqual(permissionCalls.at(-1)?.target, { + id: "rec_delete_cached", + type: "recording", + }); + assert.equal(event?.permission, "recording:delete"); + assert.equal(event?.target.id, "rec_delete_cached"); + assert.equal(event?.details.cacheDeleted, true); + assert.equal(event?.details.cached, true); + assert.equal(before?.recording?.id, "rec_delete_cached"); +}); + +test("recording delete rejects active recordings and audits failure", async () => { + const auditStore = createAuditStore(""); + const recordingStore = memoryRecordingStore([ + recording({ + id: "rec_delete_active", + name: "Active Recording", + status: "recording", + }), + ]); + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls: [], + profiles: [defaultVoiceRecordingProfile], + recordingStore, + }); + + const response = await app.request("/api/v1/recordings/rec_delete_active", { + method: "DELETE", + }); + const stored = await recordingStore.find("rec_delete_active"); + const [event] = await auditStore.list({ action: "recordings.delete.failed" }); + + assert.equal(response.status, 409); + assert.equal(stored?.id, "rec_delete_active"); + assert.equal(event?.permission, "recording:delete"); + assert.equal(event?.reason, "recording_active"); + assert.equal(event?.target.id, "rec_delete_active"); +}); diff --git a/apps/api/test/recording-routes-harness.ts b/apps/api/test/recording-routes-harness.ts new file mode 100644 index 0000000..7429960 --- /dev/null +++ b/apps/api/test/recording-routes-harness.ts @@ -0,0 +1,308 @@ +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { Hono } from "hono"; +import type { + AuditEvent, + CurrentUser, + Permission, + RecorderNode, + RecordingProfile, + RecordingSummary, +} from "@rakkr/shared"; +import type { AuthResult } from "../src/auth-service.js"; +import type { + AppBindings, + AuditTarget, + RecordAuditEvent, + RequirePermission, +} from "../src/http-types.js"; +import type { NodeStore } from "../src/node-store.js"; +import type { RecordingStore } from "../src/recording-store.js"; +import type { SettingsStore } from "../src/settings-store.js"; + +export const routeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-recording-routes-")); +process.env.DATABASE_URL = ""; +process.env.RAKKR_RECORDING_CACHE_DIR = path.join(routeRoot, "recording-cache"); +process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(routeRoot, "jobs.json"); +process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(routeRoot, "upload-policies.json"); + +const { createAuditStore } = await import("../src/audit-store.js"); +const { registerRecordingRoutes } = await import("../src/recording-routes.js"); + +export { createAuditStore }; + +test.after(async () => { + await rm(routeRoot, { force: true, recursive: true }); +}); + +export interface PermissionCall { + action: string; + permission: Permission; + target?: AuditTarget; +} + +export function recordingApp({ + auditStore, + nodes, + permissionCalls, + profiles, + recordingStore, + visibleRecordingIds, +}: { + auditStore: ReturnType; + nodes: RecorderNode[]; + permissionCalls: PermissionCall[]; + profiles: RecordingProfile[]; + recordingStore: RecordingStore; + visibleRecordingIds?: string[]; +}) { + const app = new Hono(); + + registerRecordingRoutes({ + app, + currentAuth: () => auth(), + currentUser: () => user(), + nodeStore: memoryNodeStore(nodes), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore, + requirePermission: requirePermission(permissionCalls), + scopedNodes: () => memoryNodeStore(nodes).list(), + scopedRecordings: async () => { + const recordings = await recordingStore.list(); + + return visibleRecordingIds + ? recordings.filter((recording) => visibleRecordingIds.includes(recording.id)) + : recordings; + }, + settingsStore: memorySettingsStore(profiles), + }); + + return app; +} + +function requirePermission(calls: PermissionCall[]): RequirePermission { + return (permission, action, target) => { + return async (c, next) => { + calls.push({ + action, + permission, + target: target ? await target(c) : undefined, + }); + await next(); + }; + }; +} + +function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { + return async (_c, input) => { + const event: AuditEvent = { + action: input.action, + actor: input.actor ?? { + id: "user_recording_route", + name: "Recording Route User", + roles: ["operator"], + type: "user", + }, + actorContext: {}, + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: input.details ?? {}, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; +} + +function memoryNodeStore(nodes: RecorderNode[]): NodeStore { + return { + async authenticateCredential() { + return undefined; + }, + async enroll() { + throw new Error("not implemented"); + }, + async find(nodeId) { + return nodes.find((node) => node.id === nodeId); + }, + async heartbeat() { + throw new Error("not implemented"); + }, + async list() { + return nodes; + }, + async rotateCredential() { + throw new Error("not implemented"); + }, + async updateInterface() { + throw new Error("not implemented"); + }, + async update() { + throw new Error("not implemented"); + }, + }; +} + +export function memoryRecordingStore(recordings: RecordingSummary[] = []): RecordingStore { + return { + async create(recording) { + recordings.unshift(recording); + }, + async delete(recordingId) { + const index = recordings.findIndex((recording) => recording.id === recordingId); + + if (index < 0) { + return undefined; + } + + const [deleted] = recordings.splice(index, 1); + + return deleted; + }, + async find(recordingId) { + return recordings.find((recording) => recording.id === recordingId); + }, + async list() { + return recordings; + }, + async save(recording) { + const index = recordings.findIndex((candidate) => candidate.id === recording.id); + + if (index >= 0) { + recordings[index] = recording; + } + }, + async transition(recording, allowedFrom) { + const index = recordings.findIndex((candidate) => candidate.id === recording.id); + const current = recordings[index]; + + if (!current || !allowedFrom.includes(current.status)) { + return undefined; + } + + recordings[index] = recording; + + return recording; + }, + }; +} + +function memorySettingsStore(profiles: RecordingProfile[]): SettingsStore { + return { + async assignChannelMapTemplate() { + throw new Error("not implemented"); + }, + async createChannelMapTemplate() { + throw new Error("not implemented"); + }, + async findChannelMapTemplate() { + return undefined; + }, + async findRecordingProfile(profileId) { + return profiles.find((profile) => profile.id === profileId); + }, + async findWatchdogPolicy() { + return undefined; + }, + async listChannelMapAssignments() { + return []; + }, + async listChannelMapTemplates() { + return []; + }, + async listRecordingProfiles() { + return profiles; + }, + async listWatchdogPolicies() { + return []; + }, + async rollbackChannelMapAssignment() { + return undefined; + }, + async updateChannelMapTemplate() { + return undefined; + }, + async updateRecordingProfile() { + return undefined; + }, + async updateWatchdogPolicy() { + return undefined; + }, + }; +} + +function auth(): AuthResult { + return { user: user() }; +} + +function user(): CurrentUser { + return { + email: "recording-route@example.com", + groups: [], + id: "user_recording_route", + name: "Recording Route User", + permissions: ["recording:create"], + provider: "local", + resourceGrants: [], + roles: ["operator"], + }; +} + +export function recorderNode(): RecorderNode { + return { + agentVersion: "0.1.0", + alias: "Room Alpha Recorder", + hostname: "room-alpha-recorder", + id: "node_room_alpha", + interfaces: [ + { + alias: "USB Interface", + backend: "alsa", + channelCount: 2, + channels: [ + { alias: "Lectern", index: 1 }, + { alias: "Table", index: 2 }, + ], + id: "iface_usb_1", + sampleRates: [48_000], + systemName: "hw:1,0", + systemRef: "usb-1-1", + }, + ], + ipAddresses: ["10.1.2.3"], + lastSeenAt: "2026-06-18T12:00:00.000Z", + location: { + room: "Room Alpha", + site: "Main Site", + }, + status: "online", + tags: ["voice"], + }; +} + +export function recording(input: Partial): RecordingSummary { + return { + cached: false, + durationSeconds: 120, + folder: "Meetings", + healthStatus: "healthy", + id: `rec_${randomUUID()}`, + name: "Recording", + recordedAt: "2026-06-18T12:00:00.000Z", + source: "ad_hoc", + status: "completed", + tags: ["voice"], + ...input, + }; +} diff --git a/apps/api/test/recording-routes-metadata.test.ts b/apps/api/test/recording-routes-metadata.test.ts new file mode 100644 index 0000000..fa2541c --- /dev/null +++ b/apps/api/test/recording-routes-metadata.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { defaultVoiceRecordingProfile } from "@rakkr/shared"; +import type { RecordingSummary } from "@rakkr/shared"; +import { + createAuditStore, + memoryRecordingStore, + recorderNode, + recording, + recordingApp, +} from "./recording-routes-harness.js"; +import type { PermissionCall } from "./recording-routes-harness.js"; + +test("bulk metadata update organizes visible recordings and audits snapshots", async () => { + const auditStore = createAuditStore(""); + const recordingStore = memoryRecordingStore([ + recording({ + folder: "Inbox", + id: "rec_bulk_a", + name: "Bulk A", + tags: ["voice", "raw"], + }), + recording({ + folder: "Inbox", + id: "rec_bulk_b", + name: "Bulk B", + tags: ["planning"], + }), + recording({ + folder: "Inbox", + id: "rec_bulk_c", + name: "Bulk C", + tags: ["untouched"], + }), + ]); + const permissionCalls: PermissionCall[] = []; + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls, + profiles: [defaultVoiceRecordingProfile], + recordingStore, + }); + + const response = await app.request("/api/v1/recordings/bulk-metadata", { + body: JSON.stringify({ + addTags: ["reviewed", "voice"], + folder: "Meetings/Council", + recordingIds: ["rec_bulk_a", "rec_bulk_b", "rec_bulk_a"], + removeTags: ["raw"], + }), + headers: { "content-type": "application/json" }, + method: "PATCH", + }); + const body = (await response.json()) as { + data: RecordingSummary[]; + meta: { updatedCount: number }; + }; + const updatedA = await recordingStore.find("rec_bulk_a"); + const updatedB = await recordingStore.find("rec_bulk_b"); + const untouched = await recordingStore.find("rec_bulk_c"); + const [event] = await auditStore.list({ action: "recordings.metadata.bulk_update.succeeded" }); + + assert.equal(response.status, 200); + assert.equal(permissionCalls.at(-1)?.permission, "recording:edit"); + assert.equal(permissionCalls.at(-1)?.action, "recordings.metadata.bulk_update"); + assert.deepEqual(permissionCalls.at(-1)?.target, { + id: "recording_collection", + type: "recording_collection", + }); + assert.equal(body.meta.updatedCount, 2); + assert.deepEqual( + body.data.map((recording) => recording.id), + ["rec_bulk_a", "rec_bulk_b"], + ); + assert.equal(updatedA?.folder, "Meetings/Council"); + assert.deepEqual(updatedA?.tags, ["voice", "reviewed"]); + assert.equal(updatedB?.folder, "Meetings/Council"); + assert.deepEqual(updatedB?.tags, ["planning", "reviewed", "voice"]); + assert.equal(untouched?.folder, "Inbox"); + assert.deepEqual(untouched?.tags, ["untouched"]); + assert.deepEqual(event?.details.fields, ["folder", "addTags", "removeTags"]); + assert.equal(event?.details.requestedCount, 3); + assert.equal(event?.details.updatedCount, 2); + assert.equal(event?.permission, "recording:edit"); + assert.equal(event?.target.type, "recording_collection"); + assert.equal( + (event?.before?.recordings as Array<{ folder: string; id: string }> | undefined)?.[0]?.folder, + "Inbox", + ); + assert.equal( + (event?.after?.recordings as Array<{ folder: string; id: string }> | undefined)?.[0]?.folder, + "Meetings/Council", + ); +}); + +test("bulk metadata update rejects recordings outside scoped visibility", async () => { + const auditStore = createAuditStore(""); + const recordingStore = memoryRecordingStore([ + recording({ folder: "Visible", id: "rec_visible", tags: ["voice"] }), + recording({ folder: "Hidden", id: "rec_hidden", tags: ["blocked"] }), + ]); + const app = recordingApp({ + auditStore, + nodes: [recorderNode()], + permissionCalls: [], + profiles: [defaultVoiceRecordingProfile], + recordingStore, + visibleRecordingIds: ["rec_visible"], + }); + + const response = await app.request("/api/v1/recordings/bulk-metadata", { + body: JSON.stringify({ + folder: "Meetings/Restricted", + recordingIds: ["rec_visible", "rec_hidden"], + }), + headers: { "content-type": "application/json" }, + method: "PATCH", + }); + const visible = await recordingStore.find("rec_visible"); + const hidden = await recordingStore.find("rec_hidden"); + const [event] = await auditStore.list({ action: "recordings.metadata.bulk_update.failed" }); + + assert.equal(response.status, 404); + assert.equal(visible?.folder, "Visible"); + assert.equal(hidden?.folder, "Hidden"); + assert.equal(event?.outcome, "denied"); + assert.equal(event?.reason, "recording_not_visible"); + assert.deepEqual(event?.details.hiddenIds, ["rec_hidden"]); +}); + +test("ad hoc recording start audits missing dependencies", async () => { + const auditStore = createAuditStore(""); + const node = recorderNode(); + const app = recordingApp({ + auditStore, + nodes: [node], + permissionCalls: [], + profiles: [defaultVoiceRecordingProfile], + recordingStore: memoryRecordingStore(), + }); + + const missingNode = await app.request("/api/v1/recordings", { + body: JSON.stringify({ nodeId: "node_missing" }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const missingProfile = await app.request("/api/v1/recordings", { + body: JSON.stringify({ nodeId: node.id, recordingProfileId: "profile_missing" }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const missingPolicy = await app.request("/api/v1/recordings", { + body: JSON.stringify({ nodeId: node.id, uploadPolicyIds: ["policy_missing"] }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const events = await auditStore.list({ action: "recordings.start.failed" }); + + assert.equal(missingNode.status, 404); + assert.equal(missingProfile.status, 404); + assert.equal(missingPolicy.status, 404); + assert.deepEqual( + events.map((event) => event.reason), + ["upload_policy_not_found", "recording_profile_not_found", "node_not_found"], + ); +}); diff --git a/apps/api/test/recording-routes.test.ts b/apps/api/test/recording-routes.test.ts index 22e6bac..921c355 100644 --- a/apps/api/test/recording-routes.test.ts +++ b/apps/api/test/recording-routes.test.ts @@ -1,42 +1,15 @@ import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import test from "node:test"; -import { Hono } from "hono"; import { defaultVoiceRecordingProfile } from "@rakkr/shared"; -import type { - AuditEvent, - CurrentUser, - Permission, - RecorderNode, - RecordingProfile, - RecordingSummary, -} from "@rakkr/shared"; -import type { AuthResult } from "../src/auth-service.js"; -import type { - AppBindings, - AuditTarget, - RecordAuditEvent, - RequirePermission, -} from "../src/http-types.js"; -import type { NodeStore } from "../src/node-store.js"; -import type { RecordingStore } from "../src/recording-store.js"; -import type { SettingsStore } from "../src/settings-store.js"; - -const routeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-recording-routes-")); -process.env.DATABASE_URL = ""; -process.env.RAKKR_RECORDING_CACHE_DIR = path.join(routeRoot, "recording-cache"); -process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(routeRoot, "jobs.json"); -process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(routeRoot, "upload-policies.json"); - -const { createAuditStore } = await import("../src/audit-store.js"); -const { registerRecordingRoutes } = await import("../src/recording-routes.js"); - -test.after(async () => { - await rm(routeRoot, { force: true, recursive: true }); -}); +import type { RecordingSummary } from "@rakkr/shared"; +import { + createAuditStore, + memoryRecordingStore, + recorderNode, + recording, + recordingApp, +} from "./recording-routes-harness.js"; +import type { PermissionCall } from "./recording-routes-harness.js"; test("recording facets summarize visible library relationships", async () => { const auditStore = createAuditStore(""); @@ -346,644 +319,3 @@ test("recording list filters by profile upload policy and track group", async () ["rec_manual"], ); }); - -test("bulk metadata update organizes visible recordings and audits snapshots", async () => { - const auditStore = createAuditStore(""); - const recordingStore = memoryRecordingStore([ - recording({ - folder: "Inbox", - id: "rec_bulk_a", - name: "Bulk A", - tags: ["voice", "raw"], - }), - recording({ - folder: "Inbox", - id: "rec_bulk_b", - name: "Bulk B", - tags: ["planning"], - }), - recording({ - folder: "Inbox", - id: "rec_bulk_c", - name: "Bulk C", - tags: ["untouched"], - }), - ]); - const permissionCalls: PermissionCall[] = []; - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls, - profiles: [defaultVoiceRecordingProfile], - recordingStore, - }); - - const response = await app.request("/api/v1/recordings/bulk-metadata", { - body: JSON.stringify({ - addTags: ["reviewed", "voice"], - folder: "Meetings/Council", - recordingIds: ["rec_bulk_a", "rec_bulk_b", "rec_bulk_a"], - removeTags: ["raw"], - }), - headers: { "content-type": "application/json" }, - method: "PATCH", - }); - const body = (await response.json()) as { - data: RecordingSummary[]; - meta: { updatedCount: number }; - }; - const updatedA = await recordingStore.find("rec_bulk_a"); - const updatedB = await recordingStore.find("rec_bulk_b"); - const untouched = await recordingStore.find("rec_bulk_c"); - const [event] = await auditStore.list({ action: "recordings.metadata.bulk_update.succeeded" }); - - assert.equal(response.status, 200); - assert.equal(permissionCalls.at(-1)?.permission, "recording:edit"); - assert.equal(permissionCalls.at(-1)?.action, "recordings.metadata.bulk_update"); - assert.deepEqual(permissionCalls.at(-1)?.target, { - id: "recording_collection", - type: "recording_collection", - }); - assert.equal(body.meta.updatedCount, 2); - assert.deepEqual( - body.data.map((recording) => recording.id), - ["rec_bulk_a", "rec_bulk_b"], - ); - assert.equal(updatedA?.folder, "Meetings/Council"); - assert.deepEqual(updatedA?.tags, ["voice", "reviewed"]); - assert.equal(updatedB?.folder, "Meetings/Council"); - assert.deepEqual(updatedB?.tags, ["planning", "reviewed", "voice"]); - assert.equal(untouched?.folder, "Inbox"); - assert.deepEqual(untouched?.tags, ["untouched"]); - assert.deepEqual(event?.details.fields, ["folder", "addTags", "removeTags"]); - assert.equal(event?.details.requestedCount, 3); - assert.equal(event?.details.updatedCount, 2); - assert.equal(event?.permission, "recording:edit"); - assert.equal(event?.target.type, "recording_collection"); - assert.equal( - (event?.before?.recordings as Array<{ folder: string; id: string }> | undefined)?.[0]?.folder, - "Inbox", - ); - assert.equal( - (event?.after?.recordings as Array<{ folder: string; id: string }> | undefined)?.[0]?.folder, - "Meetings/Council", - ); -}); - -test("bulk metadata update rejects recordings outside scoped visibility", async () => { - const auditStore = createAuditStore(""); - const recordingStore = memoryRecordingStore([ - recording({ folder: "Visible", id: "rec_visible", tags: ["voice"] }), - recording({ folder: "Hidden", id: "rec_hidden", tags: ["blocked"] }), - ]); - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls: [], - profiles: [defaultVoiceRecordingProfile], - recordingStore, - visibleRecordingIds: ["rec_visible"], - }); - - const response = await app.request("/api/v1/recordings/bulk-metadata", { - body: JSON.stringify({ - folder: "Meetings/Restricted", - recordingIds: ["rec_visible", "rec_hidden"], - }), - headers: { "content-type": "application/json" }, - method: "PATCH", - }); - const visible = await recordingStore.find("rec_visible"); - const hidden = await recordingStore.find("rec_hidden"); - const [event] = await auditStore.list({ action: "recordings.metadata.bulk_update.failed" }); - - assert.equal(response.status, 404); - assert.equal(visible?.folder, "Visible"); - assert.equal(hidden?.folder, "Hidden"); - assert.equal(event?.outcome, "denied"); - assert.equal(event?.reason, "recording_not_visible"); - assert.deepEqual(event?.details.hiddenIds, ["rec_hidden"]); -}); - -test("bulk recording delete removes terminal recordings and audits one snapshot", async () => { - const auditStore = createAuditStore(""); - const cachePath = "ad-hoc/rec_bulk_delete_cached.mp3"; - const cacheFilePath = path.join(routeRoot, "recording-cache", cachePath); - const recordingStore = memoryRecordingStore([ - recording({ - cached: true, - cachePath, - id: "rec_bulk_delete_cached", - status: "cached", - }), - recording({ id: "rec_bulk_delete_terminal", status: "completed" }), - recording({ id: "rec_bulk_delete_keep", status: "completed" }), - ]); - const permissionCalls: PermissionCall[] = []; - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls, - profiles: [defaultVoiceRecordingProfile], - recordingStore, - }); - - await mkdir(path.dirname(cacheFilePath), { recursive: true }); - await writeFile(cacheFilePath, Buffer.from("cached audio")); - - const response = await app.request("/api/v1/recordings/bulk-delete", { - body: JSON.stringify({ - recordingIds: [ - "rec_bulk_delete_cached", - "rec_bulk_delete_terminal", - "rec_bulk_delete_cached", - ], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const body = (await response.json()) as { - data: RecordingSummary[]; - meta: { cacheDeletedCount: number; deletedCount: number }; - }; - const deletedCached = await recordingStore.find("rec_bulk_delete_cached"); - const deletedTerminal = await recordingStore.find("rec_bulk_delete_terminal"); - const kept = await recordingStore.find("rec_bulk_delete_keep"); - const [event] = await auditStore.list({ action: "recordings.bulk_delete.succeeded" }); - const before = event?.before as { recordings?: RecordingSummary[] } | undefined; - - assert.equal(response.status, 200); - assert.equal(deletedCached, undefined); - assert.equal(deletedTerminal, undefined); - assert.equal(kept?.id, "rec_bulk_delete_keep"); - await assert.rejects( - readFile(cacheFilePath), - (error) => error instanceof Error && "code" in error && error.code === "ENOENT", - ); - assert.equal(permissionCalls.at(-1)?.permission, "recording:delete"); - assert.equal(permissionCalls.at(-1)?.action, "recordings.bulk_delete"); - assert.equal(body.meta.deletedCount, 2); - assert.equal(body.meta.cacheDeletedCount, 1); - assert.deepEqual( - body.data.map((recording) => recording.id), - ["rec_bulk_delete_cached", "rec_bulk_delete_terminal"], - ); - assert.equal(event?.permission, "recording:delete"); - assert.equal(event?.target.type, "recording_collection"); - assert.equal(event?.details.requestedCount, 3); - assert.equal(event?.details.deletedCount, 2); - assert.equal(before?.recordings?.[0]?.id, "rec_bulk_delete_cached"); -}); - -test("bulk recording delete rejects hidden recordings", async () => { - const auditStore = createAuditStore(""); - const recordingStore = memoryRecordingStore([ - recording({ id: "rec_bulk_delete_visible", status: "completed" }), - recording({ id: "rec_bulk_delete_hidden", status: "completed" }), - ]); - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls: [], - profiles: [defaultVoiceRecordingProfile], - recordingStore, - visibleRecordingIds: ["rec_bulk_delete_visible"], - }); - - const response = await app.request("/api/v1/recordings/bulk-delete", { - body: JSON.stringify({ - recordingIds: ["rec_bulk_delete_visible", "rec_bulk_delete_hidden"], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const visible = await recordingStore.find("rec_bulk_delete_visible"); - const hidden = await recordingStore.find("rec_bulk_delete_hidden"); - const [event] = await auditStore.list({ action: "recordings.bulk_delete.failed" }); - - assert.equal(response.status, 404); - assert.equal(visible?.id, "rec_bulk_delete_visible"); - assert.equal(hidden?.id, "rec_bulk_delete_hidden"); - assert.equal(event?.outcome, "denied"); - assert.equal(event?.reason, "recording_not_visible"); - assert.deepEqual(event?.details.hiddenIds, ["rec_bulk_delete_hidden"]); -}); - -test("bulk recording delete rejects active recordings before deleting anything", async () => { - const auditStore = createAuditStore(""); - const recordingStore = memoryRecordingStore([ - recording({ id: "rec_bulk_delete_ready", status: "completed" }), - recording({ id: "rec_bulk_delete_active", status: "recording" }), - ]); - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls: [], - profiles: [defaultVoiceRecordingProfile], - recordingStore, - }); - - const response = await app.request("/api/v1/recordings/bulk-delete", { - body: JSON.stringify({ - recordingIds: ["rec_bulk_delete_ready", "rec_bulk_delete_active"], - }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const ready = await recordingStore.find("rec_bulk_delete_ready"); - const active = await recordingStore.find("rec_bulk_delete_active"); - const [event] = await auditStore.list({ action: "recordings.bulk_delete.failed" }); - - assert.equal(response.status, 409); - assert.equal(ready?.id, "rec_bulk_delete_ready"); - assert.equal(active?.id, "rec_bulk_delete_active"); - assert.equal(event?.reason, "recording_active"); - assert.deepEqual(event?.details.activeIds, ["rec_bulk_delete_active"]); -}); - -test("recording delete removes terminal metadata cached file and audits snapshot", async () => { - const auditStore = createAuditStore(""); - const cachePath = "ad-hoc/rec_delete_cached.mp3"; - const cacheFilePath = path.join(routeRoot, "recording-cache", cachePath); - const recordingStore = memoryRecordingStore([ - recording({ - cached: true, - cachePath, - id: "rec_delete_cached", - name: "Delete Cached Recording", - status: "cached", - }), - ]); - const permissionCalls: PermissionCall[] = []; - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls, - profiles: [defaultVoiceRecordingProfile], - recordingStore, - }); - - await mkdir(path.dirname(cacheFilePath), { recursive: true }); - await writeFile(cacheFilePath, Buffer.from("cached audio")); - - const response = await app.request("/api/v1/recordings/rec_delete_cached", { - method: "DELETE", - }); - const stored = await recordingStore.find("rec_delete_cached"); - const [event] = await auditStore.list({ action: "recordings.delete.succeeded" }); - const before = event?.before as { recording?: RecordingSummary } | undefined; - - assert.equal(response.status, 204); - assert.equal(stored, undefined); - await assert.rejects( - readFile(cacheFilePath), - (error) => error instanceof Error && "code" in error && error.code === "ENOENT", - ); - assert.equal(permissionCalls.at(-1)?.permission, "recording:delete"); - assert.equal(permissionCalls.at(-1)?.action, "recordings.delete"); - assert.deepEqual(permissionCalls.at(-1)?.target, { - id: "rec_delete_cached", - type: "recording", - }); - assert.equal(event?.permission, "recording:delete"); - assert.equal(event?.target.id, "rec_delete_cached"); - assert.equal(event?.details.cacheDeleted, true); - assert.equal(event?.details.cached, true); - assert.equal(before?.recording?.id, "rec_delete_cached"); -}); - -test("recording delete rejects active recordings and audits failure", async () => { - const auditStore = createAuditStore(""); - const recordingStore = memoryRecordingStore([ - recording({ - id: "rec_delete_active", - name: "Active Recording", - status: "recording", - }), - ]); - const app = recordingApp({ - auditStore, - nodes: [recorderNode()], - permissionCalls: [], - profiles: [defaultVoiceRecordingProfile], - recordingStore, - }); - - const response = await app.request("/api/v1/recordings/rec_delete_active", { - method: "DELETE", - }); - const stored = await recordingStore.find("rec_delete_active"); - const [event] = await auditStore.list({ action: "recordings.delete.failed" }); - - assert.equal(response.status, 409); - assert.equal(stored?.id, "rec_delete_active"); - assert.equal(event?.permission, "recording:delete"); - assert.equal(event?.reason, "recording_active"); - assert.equal(event?.target.id, "rec_delete_active"); -}); - -test("ad hoc recording start audits missing dependencies", async () => { - const auditStore = createAuditStore(""); - const node = recorderNode(); - const app = recordingApp({ - auditStore, - nodes: [node], - permissionCalls: [], - profiles: [defaultVoiceRecordingProfile], - recordingStore: memoryRecordingStore(), - }); - - const missingNode = await app.request("/api/v1/recordings", { - body: JSON.stringify({ nodeId: "node_missing" }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const missingProfile = await app.request("/api/v1/recordings", { - body: JSON.stringify({ nodeId: node.id, recordingProfileId: "profile_missing" }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const missingPolicy = await app.request("/api/v1/recordings", { - body: JSON.stringify({ nodeId: node.id, uploadPolicyIds: ["policy_missing"] }), - headers: { "content-type": "application/json" }, - method: "POST", - }); - const events = await auditStore.list({ action: "recordings.start.failed" }); - - assert.equal(missingNode.status, 404); - assert.equal(missingProfile.status, 404); - assert.equal(missingPolicy.status, 404); - assert.deepEqual( - events.map((event) => event.reason), - ["upload_policy_not_found", "recording_profile_not_found", "node_not_found"], - ); -}); - -interface PermissionCall { - action: string; - permission: Permission; - target?: AuditTarget; -} - -function recordingApp({ - auditStore, - nodes, - permissionCalls, - profiles, - recordingStore, - visibleRecordingIds, -}: { - auditStore: ReturnType; - nodes: RecorderNode[]; - permissionCalls: PermissionCall[]; - profiles: RecordingProfile[]; - recordingStore: RecordingStore; - visibleRecordingIds?: string[]; -}) { - const app = new Hono(); - - registerRecordingRoutes({ - app, - currentAuth: () => auth(), - currentUser: () => user(), - nodeStore: memoryNodeStore(nodes), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore, - requirePermission: requirePermission(permissionCalls), - scopedNodes: () => memoryNodeStore(nodes).list(), - scopedRecordings: async () => { - const recordings = await recordingStore.list(); - - return visibleRecordingIds - ? recordings.filter((recording) => visibleRecordingIds.includes(recording.id)) - : recordings; - }, - settingsStore: memorySettingsStore(profiles), - }); - - return app; -} - -function requirePermission(calls: PermissionCall[]): RequirePermission { - return (permission, action, target) => { - return async (c, next) => { - calls.push({ - action, - permission, - target: target ? await target(c) : undefined, - }); - await next(); - }; - }; -} - -function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { - return async (_c, input) => { - const event: AuditEvent = { - action: input.action, - actor: input.actor ?? { - id: "user_recording_route", - name: "Recording Route User", - roles: ["operator"], - type: "user", - }, - actorContext: {}, - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: input.details ?? {}, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; - }; -} - -function memoryNodeStore(nodes: RecorderNode[]): NodeStore { - return { - async authenticateCredential() { - return undefined; - }, - async enroll() { - throw new Error("not implemented"); - }, - async find(nodeId) { - return nodes.find((node) => node.id === nodeId); - }, - async heartbeat() { - throw new Error("not implemented"); - }, - async list() { - return nodes; - }, - async rotateCredential() { - throw new Error("not implemented"); - }, - async updateInterface() { - throw new Error("not implemented"); - }, - async update() { - throw new Error("not implemented"); - }, - }; -} - -function memoryRecordingStore(recordings: RecordingSummary[] = []): RecordingStore { - return { - async create(recording) { - recordings.unshift(recording); - }, - async delete(recordingId) { - const index = recordings.findIndex((recording) => recording.id === recordingId); - - if (index < 0) { - return undefined; - } - - const [deleted] = recordings.splice(index, 1); - - return deleted; - }, - async find(recordingId) { - return recordings.find((recording) => recording.id === recordingId); - }, - async list() { - return recordings; - }, - async save(recording) { - const index = recordings.findIndex((candidate) => candidate.id === recording.id); - - if (index >= 0) { - recordings[index] = recording; - } - }, - async transition(recording, allowedFrom) { - const index = recordings.findIndex((candidate) => candidate.id === recording.id); - const current = recordings[index]; - - if (!current || !allowedFrom.includes(current.status)) { - return undefined; - } - - recordings[index] = recording; - - return recording; - }, - }; -} - -function memorySettingsStore(profiles: RecordingProfile[]): SettingsStore { - return { - async assignChannelMapTemplate() { - throw new Error("not implemented"); - }, - async createChannelMapTemplate() { - throw new Error("not implemented"); - }, - async findChannelMapTemplate() { - return undefined; - }, - async findRecordingProfile(profileId) { - return profiles.find((profile) => profile.id === profileId); - }, - async findWatchdogPolicy() { - return undefined; - }, - async listChannelMapAssignments() { - return []; - }, - async listChannelMapTemplates() { - return []; - }, - async listRecordingProfiles() { - return profiles; - }, - async listWatchdogPolicies() { - return []; - }, - async rollbackChannelMapAssignment() { - return undefined; - }, - async updateChannelMapTemplate() { - return undefined; - }, - async updateRecordingProfile() { - return undefined; - }, - async updateWatchdogPolicy() { - return undefined; - }, - }; -} - -function auth(): AuthResult { - return { user: user() }; -} - -function user(): CurrentUser { - return { - email: "recording-route@example.com", - groups: [], - id: "user_recording_route", - name: "Recording Route User", - permissions: ["recording:create"], - provider: "local", - resourceGrants: [], - roles: ["operator"], - }; -} - -function recorderNode(): RecorderNode { - return { - agentVersion: "0.1.0", - alias: "Room Alpha Recorder", - hostname: "room-alpha-recorder", - id: "node_room_alpha", - interfaces: [ - { - alias: "USB Interface", - backend: "alsa", - channelCount: 2, - channels: [ - { alias: "Lectern", index: 1 }, - { alias: "Table", index: 2 }, - ], - id: "iface_usb_1", - sampleRates: [48_000], - systemName: "hw:1,0", - systemRef: "usb-1-1", - }, - ], - ipAddresses: ["10.1.2.3"], - lastSeenAt: "2026-06-18T12:00:00.000Z", - location: { - room: "Room Alpha", - site: "Main Site", - }, - status: "online", - tags: ["voice"], - }; -} - -function recording(input: Partial): RecordingSummary { - return { - cached: false, - durationSeconds: 120, - folder: "Meetings", - healthStatus: "healthy", - id: `rec_${randomUUID()}`, - name: "Recording", - recordedAt: "2026-06-18T12:00:00.000Z", - source: "ad_hoc", - status: "completed", - tags: ["voice"], - ...input, - }; -} diff --git a/apps/api/test/schedule-routes-actions.test.ts b/apps/api/test/schedule-routes-actions.test.ts new file mode 100644 index 0000000..c76caa4 --- /dev/null +++ b/apps/api/test/schedule-routes-actions.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Hono } from "hono"; +import type { AppBindings } from "../src/http-types.js"; +import { + type ScheduleActionsResponse, + allowPermission, + createAuditStore, + createNodeStore, + createSettingsStore, + node, + recordAuditEvent, + recordingStore, + registerScheduleRoutes, + schedule, + scheduleStore, + user, +} from "./schedule-routes-harness.js"; + +test("schedule action summary returns scoped readiness links and node context", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = user(["schedule:read", "schedule:manage"]); + const visible = schedule({ + id: `sched_actions_visible_${randomUUID()}`, + name: "Visible Actions", + nodeId: "node_schedule_actions", + }); + const hidden = schedule({ id: `sched_actions_hidden_${randomUUID()}` }); + const store = scheduleStore([visible, hidden]); + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([node({ id: visible.nodeId })]), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore: recordingStore(), + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [node({ id: visible.nodeId })], + scopedSchedules: async () => [visible], + settingsStore: createSettingsStore(), + }); + + const visibleResponse = await app.request(`/api/v1/schedules/${visible.id}/actions`); + const hiddenResponse = await app.request(`/api/v1/schedules/${hidden.id}/actions`); + const body = (await visibleResponse.json()) as ScheduleActionsResponse; + const [succeededEvent] = await auditStore.list({ + action: "schedules.actions.read.succeeded", + }); + const [failedEvent] = await auditStore.list({ action: "schedules.actions.read.failed" }); + + assert.equal(visibleResponse.status, 200); + assert.equal(hiddenResponse.status, 404); + assert.equal(body.data.schedule.id, visible.id); + assert.equal(body.data.node?.id, visible.nodeId); + assert.equal(body.data.actions.edit.enabled, true); + assert.equal(body.data.actions.delete.enabled, true); + assert.equal(body.data.actions.runNow.enabled, true); + assert.equal(body.data.actions.skipNext.enabled, true); + assert.equal(body.data.actions.occurrences.enabled, true); + assert.equal(body.data.links.runNow, `/api/v1/schedules/${visible.id}/run-now`); + assert.equal(body.data.links.occurrences, `/api/v1/schedules/${visible.id}/occurrences`); + assert.equal(succeededEvent?.outcome, "succeeded"); + assert.equal(succeededEvent?.permission, "schedule:read"); + assert.equal(succeededEvent?.target.id, visible.id); + assert.equal(succeededEvent?.target.name, visible.name); + assert.equal(succeededEvent?.details.nodeAvailable, true); + assert.equal(succeededEvent?.details.scheduleEnabled, true); + assert.equal(succeededEvent?.details.visibleActionCount, 5); + assert.equal(failedEvent?.outcome, "failed"); + assert.equal(failedEvent?.permission, "schedule:read"); + assert.equal(failedEvent?.reason, "schedule_not_found"); + assert.equal(failedEvent?.target.id, hidden.id); +}); + +test("schedule action summary reports lifecycle blockers for disabled schedules", async () => { + const app = new Hono(); + const currentUser = user(["schedule:read", "schedule:manage"]); + const disabled = schedule({ + enabled: false, + id: `sched_actions_disabled_${randomUUID()}`, + name: "Disabled Actions", + }); + const store = scheduleStore([disabled]); + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([node()]), + recordAuditEvent: recordAuditEvent(createAuditStore("")), + recordingStore: recordingStore(), + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [node()], + scopedSchedules: async () => [disabled], + settingsStore: createSettingsStore(), + }); + + const response = await app.request(`/api/v1/schedules/${disabled.id}/actions`); + const body = (await response.json()) as ScheduleActionsResponse; + + assert.equal(response.status, 200); + assert.equal(body.data.actions.runNow.enabled, false); + assert.equal(body.data.actions.runNow.reason, "schedule_disabled"); + assert.equal(body.data.actions.skipNext.enabled, false); + assert.equal(body.data.actions.skipNext.reason, "schedule_disabled"); +}); + +test("schedule action summary hides out-of-scope node context and readiness", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = user(["schedule:read", "schedule:manage"]); + const visibleNode = node({ id: "node_schedule_action_visible" }); + const hiddenNode = node({ id: "node_schedule_action_hidden" }); + const scheduleOnHiddenNode = schedule({ + id: "sched_action_hidden_node", + name: "Hidden Node Action Summary", + nodeId: hiddenNode.id, + }); + const store = scheduleStore([scheduleOnHiddenNode]); + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([visibleNode, hiddenNode]), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore: recordingStore(), + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [visibleNode], + scopedSchedules: async () => [scheduleOnHiddenNode], + settingsStore: createSettingsStore(), + }); + + const response = await app.request(`/api/v1/schedules/${scheduleOnHiddenNode.id}/actions`); + const body = (await response.json()) as ScheduleActionsResponse; + const [event] = await auditStore.list({ action: "schedules.actions.read.succeeded" }); + + assert.equal(response.status, 200); + assert.equal(body.data.schedule.id, scheduleOnHiddenNode.id); + assert.equal(body.data.node, undefined); + assert.equal(body.data.actions.runNow.enabled, false); + assert.equal(body.data.actions.runNow.reason, "schedule_node_not_found"); + assert.equal(body.data.actions.skipNext.enabled, true); + assert.equal(event?.details.nodeAvailable, false); + assert.equal(event?.target.id, scheduleOnHiddenNode.id); +}); diff --git a/apps/api/test/schedule-routes-harness.ts b/apps/api/test/schedule-routes-harness.ts new file mode 100644 index 0000000..dabd478 --- /dev/null +++ b/apps/api/test/schedule-routes-harness.ts @@ -0,0 +1,239 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { Hono } from "hono"; +import type { + AuditEvent, + CurrentUser, + Permission, + RecorderNode, + RecordingSummary, + ScheduleSummary, +} from "@rakkr/shared"; +import type { AppBindings, RecordAuditEvent, RequirePermission } from "../src/http-types.js"; +import type { ScheduleStore } from "../src/schedule-store.js"; + +const routeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-schedule-routes-")); +process.env.DATABASE_URL = ""; +process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(routeRoot, "recording-jobs.json"); + +const { createAuditStore } = await import("../src/audit-store.js"); +const { createNodeStore } = await import("../src/node-store.js"); +const { registerScheduleRoutes } = await import("../src/schedule-routes.js"); +const { createSettingsStore } = await import("../src/settings-store.js"); + +export { createAuditStore, createNodeStore, registerScheduleRoutes, createSettingsStore }; + +test.after(async () => { + await rm(routeRoot, { force: true, recursive: true }); +}); + +export function requestJson( + app: Hono, + path: string, + method: "PATCH" | "POST", + body: Record, +) { + return app.request(path, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method, + }); +} + +export async function scheduleList(app: Hono, query = "") { + const response = await app.request(`/api/v1/schedules${query}`); + const body = (await response.json()) as { data: ScheduleSummary[] }; + + assert.equal(response.status, 200); + + return body.data; +} + +export interface ScheduleActionsResponse { + data: { + actions: Record; + links: Record; + node?: RecorderNode; + schedule: ScheduleSummary; + }; +} + +export function allowPermission(): RequirePermission { + return () => async (_c, next) => { + await next(); + }; +} + +export function denyMissingPermission( + auditStore: ReturnType, + currentUser: CurrentUser, +): RequirePermission { + return (permission, action, target) => async (c) => { + const auditTarget = target ? await target(c) : { type: "controller" as const }; + + await recordAuditEvent(auditStore)(c, { + action, + auth: { user: currentUser }, + details: { + requiredPermission: permission, + resourceScope: auditTarget, + roles: currentUser.roles, + }, + outcome: "denied", + permission, + reason: "missing_permission", + target: auditTarget, + }); + + return c.json({ error: "Forbidden", permission }, 403); + }; +} + +export function recordAuditEvent( + auditStore: ReturnType, +): RecordAuditEvent { + return async (_c, input) => { + const actor = input.actor ?? { + id: input.auth?.user?.id ?? "anonymous", + name: input.auth?.user?.name ?? "Anonymous", + roles: input.auth?.user?.roles ?? [], + type: "user" as const, + }; + const event: AuditEvent = { + action: input.action, + actor, + actorContext: {}, + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: input.details ?? {}, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; +} + +export function scheduleStore(schedules: ScheduleSummary[]): ScheduleStore { + return { + async create(schedule) { + schedules.unshift(schedule); + + return schedule; + }, + async delete(scheduleId) { + const index = schedules.findIndex((candidate) => candidate.id === scheduleId); + const [deleted] = index >= 0 ? schedules.splice(index, 1) : []; + + return deleted; + }, + async find(scheduleId) { + return schedules.find((candidate) => candidate.id === scheduleId); + }, + async list() { + return schedules; + }, + async update(scheduleId, update) { + const index = schedules.findIndex((candidate) => candidate.id === scheduleId); + + if (index < 0) { + return undefined; + } + + schedules[index] = { ...schedules[index], ...update }; + + return schedules[index]; + }, + }; +} + +export function recordingStore() { + const recordings: RecordingSummary[] = []; + + return { + async create(recording: RecordingSummary) { + recordings.unshift(recording); + }, + async delete(recordingId: string) { + const index = recordings.findIndex((candidate) => candidate.id === recordingId); + const [deleted] = index >= 0 ? recordings.splice(index, 1) : []; + + return deleted; + }, + async find(recordingId: string) { + return recordings.find((candidate) => candidate.id === recordingId); + }, + async list() { + return recordings; + }, + async save(recording: RecordingSummary) { + recordings.unshift(recording); + }, + }; +} + +export function user(permissions: Permission[] = ["schedule:read"]): CurrentUser { + return { + email: "schedule-viewer@example.com", + groups: [], + id: "user_schedule_viewer_test", + name: "Schedule Viewer Test", + permissions, + provider: "local", + resourceGrants: [], + roles: ["viewer"], + }; +} + +export function node(input: Partial = {}): RecorderNode { + return { + agentVersion: "0.1.0", + alias: "Schedule Node", + hostname: "schedule-node", + id: "node_schedule_test", + interfaces: [], + ipAddresses: ["10.0.0.60"], + lastSeenAt: "2026-06-18T12:00:00.000Z", + location: { + room: "Council Room", + site: "Main Site", + }, + status: "online", + tags: ["voice"], + ...input, + }; +} + +export function schedule(input: Partial = {}): ScheduleSummary { + return { + assignedGroupIds: [], + assignedUserIds: [], + enabled: true, + folderTemplate: "meetings/{{date}}", + id: "sched_route_test", + name: "Council Meeting", + nextRunAt: "2026-06-18T09:00:00.000Z", + nodeId: node().id, + recurrence: { mode: "manual" }, + recordingProfileId: "voice-mp3-vbr", + retentionPolicyId: "retention-keep-controller-cache", + room: "Council Room", + tags: ["council"], + timezone: "UTC", + titleTemplate: "{{date}} Council Meeting", + uploadPolicyIds: ["upload-policy-stub"], + watchdogPolicyId: "scheduled-voice-watchdog", + ...input, + }; +} diff --git a/apps/api/test/schedule-routes-lifecycle.test.ts b/apps/api/test/schedule-routes-lifecycle.test.ts new file mode 100644 index 0000000..6a95355 --- /dev/null +++ b/apps/api/test/schedule-routes-lifecycle.test.ts @@ -0,0 +1,338 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Hono } from "hono"; +import type { RecordingSummary, ScheduleSummary } from "@rakkr/shared"; +import type { AppBindings } from "../src/http-types.js"; +import { + allowPermission, + createAuditStore, + createNodeStore, + createSettingsStore, + node, + recordAuditEvent, + recordingStore, + registerScheduleRoutes, + requestJson, + schedule, + scheduleStore, + user, +} from "./schedule-routes-harness.js"; + +test("schedule occurrence and lifecycle routes only operate on scoped schedules", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = user(["schedule:read", "schedule:manage"]); + const visible = schedule({ id: "sched_visible_lifecycle", name: "Visible Lifecycle" }); + const hidden = schedule({ id: "sched_hidden_lifecycle", name: "Hidden Lifecycle" }); + const store = scheduleStore([visible, hidden]); + const recordings = recordingStore(); + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([node()]), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore: recordings, + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [node()], + scopedSchedules: async () => [visible], + settingsStore: createSettingsStore(), + }); + + const occurrences = await app.request(`/api/v1/schedules/${hidden.id}/occurrences`); + const update = await requestJson(app, `/api/v1/schedules/${hidden.id}`, "PATCH", { + name: "Hidden Mutated", + }); + const runNow = await app.request(`/api/v1/schedules/${hidden.id}/run-now`, { method: "POST" }); + const skipNext = await app.request(`/api/v1/schedules/${hidden.id}/skip-next`, { + method: "POST", + }); + const deleted = await app.request(`/api/v1/schedules/${hidden.id}`, { method: "DELETE" }); + const stillHidden = await store.find(hidden.id); + const recordingList = await recordings.list(); + const failedEvents = await auditStore.list({ outcome: "failed" }); + const readFailures = failedEvents.filter((event) => event.permission === "schedule:read"); + const manageFailures = failedEvents.filter((event) => event.permission === "schedule:manage"); + + assert.deepEqual( + [occurrences.status, update.status, runNow.status, skipNext.status, deleted.status], + [404, 404, 404, 404, 404], + ); + assert.equal(stillHidden?.name, hidden.name); + assert.deepEqual(recordingList, []); + assert.deepEqual( + readFailures.map((event) => `${event.action}:${event.reason}`), + ["schedules.occurrences.read.failed:schedule_not_found"], + ); + assert.deepEqual(manageFailures.map((event) => `${event.action}:${event.reason}`).sort(), [ + "schedules.delete.failed:schedule_not_found", + "schedules.run_now.failed:schedule_not_found", + "schedules.skip_next.failed:schedule_not_found", + "schedules.update.failed:schedule_not_found", + ]); +}); + +test("disabled schedule run-now is rejected and audited", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = user(["schedule:manage", "schedule:read"]); + const disabled = schedule({ + enabled: false, + id: `sched_disabled_run_${randomUUID()}`, + nodeId: node().id, + }); + const store = scheduleStore([disabled]); + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([node()]), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore: recordingStore(), + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [node()], + scopedSchedules: async () => [disabled], + settingsStore: createSettingsStore(), + }); + + const response = await app.request(`/api/v1/schedules/${disabled.id}/run-now`, { + method: "POST", + }); + const [event] = await auditStore.list({ action: "schedules.run_now.failed" }); + + assert.equal(response.status, 409); + assert.equal(event?.reason, "schedule_disabled"); + assert.equal(event?.target.id, disabled.id); +}); + +test("schedule work routes only operate on scoped visible nodes", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = user(["schedule:manage", "schedule:read"]); + const visibleNode = node({ id: "node_schedule_visible" }); + const hiddenNode = node({ id: "node_schedule_hidden" }); + const visible = schedule({ + id: "sched_visible_node_scope", + nodeId: visibleNode.id, + }); + const hiddenNodeSchedule = schedule({ + id: "sched_hidden_node_scope", + nodeId: hiddenNode.id, + }); + const store = scheduleStore([visible, hiddenNodeSchedule]); + const recordings = recordingStore(); + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([visibleNode, hiddenNode]), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore: recordings, + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [visibleNode], + scopedSchedules: () => store.list(), + settingsStore: createSettingsStore(), + }); + + const createHidden = await requestJson(app, "/api/v1/schedules", "POST", { + enabled: true, + folderTemplate: "Hidden/{{date}}", + name: "Hidden Node Schedule", + nodeId: hiddenNode.id, + recordingProfileId: "voice-mp3-vbr", + room: "Hidden Room", + timezone: "UTC", + titleTemplate: "{{date}} Hidden Node Schedule", + watchdogPolicyId: "scheduled-voice-watchdog", + }); + const updateHidden = await requestJson(app, `/api/v1/schedules/${visible.id}`, "PATCH", { + nodeId: hiddenNode.id, + }); + const runHidden = await app.request(`/api/v1/schedules/${hiddenNodeSchedule.id}/run-now`, { + method: "POST", + }); + const failedEvents = await auditStore.list({ + outcome: "failed", + permission: "schedule:manage", + }); + + assert.equal(createHidden.status, 409); + assert.equal(updateHidden.status, 409); + assert.equal(runHidden.status, 409); + assert.equal((await store.find(visible.id))?.nodeId, visibleNode.id); + assert.equal( + (await store.list()).some((candidate) => candidate.name === "Hidden Node Schedule"), + false, + ); + assert.equal((await recordings.list()).length, 0); + assert.deepEqual(failedEvents.map((event) => `${event.action}:${event.reason}`).sort(), [ + "schedules.create.failed:schedule_node_not_found", + "schedules.run_now.failed:schedule_node_not_found", + "schedules.update.failed:schedule_node_not_found", + ]); + assert.equal( + failedEvents.find((event) => event.action === "schedules.run_now.failed")?.target.id, + hiddenNodeSchedule.id, + ); +}); + +test("schedule routes create update run-now and skip-next with audit events", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = user(["recording:read", "schedule:read", "schedule:manage"]); + const routeInterfaceId = "iface_route_jack"; + const routeNode = node({ + id: `node_schedule_ops_${randomUUID()}`, + interfaces: [ + { + alias: "JACK Route", + backend: "jack", + channelCount: 2, + channels: [ + { alias: "Left", index: 1 }, + { alias: "Right", index: 2 }, + ], + id: routeInterfaceId, + sampleRates: [48_000], + systemName: "jack:route", + systemRef: "jack:route", + }, + ], + }); + const schedules: ScheduleSummary[] = []; + const store = scheduleStore(schedules); + const recordings = recordingStore(); + const scheduleId = `sched_route_ops_${randomUUID()}`; + + registerScheduleRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + currentUser: () => currentUser, + nodeStore: createNodeStore([routeNode]), + recordAuditEvent: recordAuditEvent(auditStore), + recordingStore: recordings, + requirePermission: allowPermission(), + scheduleStore: store, + scopedNodes: async () => [routeNode], + scopedSchedules: () => store.list(), + settingsStore: createSettingsStore(), + }); + + const invalidInterface = await requestJson(app, "/api/v1/schedules", "POST", { + enabled: true, + captureInterfaceId: "missing_interface", + folderTemplate: "Meetings/{{date}}/{{schedule.name}}", + name: "Invalid Interface", + nodeId: routeNode.id, + recordingProfileId: "voice-mp3-vbr", + room: "Council Room", + timezone: "UTC", + titleTemplate: "{{date}}_{{time}}_{{schedule.name}}", + watchdogPolicyId: "scheduled-voice-watchdog", + }); + const created = await requestJson(app, "/api/v1/schedules", "POST", { + captureBackend: "jack", + captureInterfaceId: routeInterfaceId, + enabled: true, + folderTemplate: "Meetings/{{date}}/{{schedule.name}}", + id: scheduleId, + name: "Council Route Test", + nodeId: routeNode.id, + recurrence: { + endTime: "10:00", + interval: 1, + mode: "daily", + startEarlySeconds: 300, + startTime: "09:00", + stopLateSeconds: 120, + }, + recordingProfileId: "voice-mp3-vbr", + retentionPolicyId: "retention-keep-controller-cache", + room: "Council Room", + tags: ["voice", "route", "voice"], + timezone: "UTC", + titleTemplate: "{{date}}_{{time}}_{{schedule.name}}", + uploadPolicyIds: ["upload-policy-stub"], + watchdogPolicyId: "scheduled-voice-watchdog", + }); + const createdBody = (await created.json()) as { data: ScheduleSummary }; + const updated = await requestJson(app, `/api/v1/schedules/${scheduleId}`, "PATCH", { + name: "Council Route Test Updated", + tags: ["updated", "voice", "updated"], + }); + const updatedBody = (await updated.json()) as { data: ScheduleSummary }; + const occurrences = await app.request(`/api/v1/schedules/${scheduleId}/occurrences?limit=2`); + const runNow = await app.request(`/api/v1/schedules/${scheduleId}/run-now`, { method: "POST" }); + const runNowBody = (await runNow.json()) as { + data: RecordingSummary; + job: { + command: { captureBackend?: string; captureDevice: string; captureInterfaceId?: string }; + recordingId: string; + }; + segments: Array<{ recordingId: string }>; + }; + const beforeSkip = await store.find(scheduleId); + const skipped = await app.request(`/api/v1/schedules/${scheduleId}/skip-next`, { + method: "POST", + }); + const skippedBody = (await skipped.json()) as { data: ScheduleSummary }; + const succeededAudits = await auditStore.list({ + outcome: "succeeded", + permission: "schedule:manage", + }); + const runNowAudit = succeededAudits.find( + (event) => event.action === "schedules.run_now.succeeded", + ); + const [occurrencesAudit] = await auditStore.list({ + action: "schedules.occurrences.read.succeeded", + }); + + assert.equal(invalidInterface.status, 409); + assert.equal(created.status, 201); + assert.equal(updated.status, 200); + assert.equal(occurrences.status, 200); + assert.equal(runNow.status, 202); + assert.equal(skipped.status, 200); + assert.equal(createdBody.data.id, scheduleId); + assert.equal(createdBody.data.captureBackend, "jack"); + assert.equal(createdBody.data.captureInterfaceId, routeInterfaceId); + assert.deepEqual(createdBody.data.tags, ["voice", "route"]); + assert.equal(updatedBody.data.name, "Council Route Test Updated"); + assert.deepEqual(updatedBody.data.tags, ["updated", "voice"]); + assert.equal(runNowBody.data.scheduleId, scheduleId); + assert.equal(runNowBody.data.retentionPolicyId, "retention-keep-controller-cache"); + assert.equal(runNowBody.job.command.captureBackend, "jack"); + assert.equal(runNowBody.job.command.captureDevice, "jack:route"); + assert.equal(runNowBody.job.command.captureInterfaceId, routeInterfaceId); + assert.equal(runNowBody.job.recordingId, runNowBody.data.id); + assert.deepEqual( + runNowBody.segments.map((segment) => segment.recordingId), + [runNowBody.data.id], + ); + assert.equal((await recordings.list())[0]?.source, "schedule"); + assert.notEqual(skippedBody.data.nextRunAt, beforeSkip?.nextRunAt); + assert.ok( + skippedBody.data.recurrence.exceptions?.some((exception) => exception.action === "skip"), + ); + assert.deepEqual(succeededAudits.map((event) => event.action).sort(), [ + "schedules.create.succeeded", + "schedules.run_now.succeeded", + "schedules.skip_next.succeeded", + "schedules.update.succeeded", + ]); + assert.equal(runNowAudit?.correlationIds?.scheduleId, scheduleId); + assert.equal(runNowAudit?.details.captureBackend, "jack"); + assert.equal(runNowAudit?.details.captureInterfaceId, routeInterfaceId); + assert.equal(runNowAudit?.after?.recordingId, runNowBody.data.id); + assert.equal(occurrencesAudit?.permission, "schedule:read"); + assert.equal(occurrencesAudit?.target.id, scheduleId); + assert.equal(occurrencesAudit?.details.requestedLimit, 2); + assert.equal(occurrencesAudit?.details.occurrenceCount, 2); +}); diff --git a/apps/api/test/schedule-routes.test.ts b/apps/api/test/schedule-routes.test.ts index 25c8f95..7f275fa 100644 --- a/apps/api/test/schedule-routes.test.ts +++ b/apps/api/test/schedule-routes.test.ts @@ -1,33 +1,24 @@ import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import test from "node:test"; import { Hono } from "hono"; -import type { - AuditEvent, - CurrentUser, - Permission, - RecorderNode, - RecordingSummary, - ScheduleSummary, -} from "@rakkr/shared"; -import type { AppBindings, RecordAuditEvent, RequirePermission } from "../src/http-types.js"; -import type { ScheduleStore } from "../src/schedule-store.js"; - -const routeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-schedule-routes-")); -process.env.DATABASE_URL = ""; -process.env.RAKKR_RECORDING_JOB_STORE_PATH = path.join(routeRoot, "recording-jobs.json"); - -const { createAuditStore } = await import("../src/audit-store.js"); -const { createNodeStore } = await import("../src/node-store.js"); -const { registerScheduleRoutes } = await import("../src/schedule-routes.js"); -const { createSettingsStore } = await import("../src/settings-store.js"); - -test.after(async () => { - await rm(routeRoot, { force: true, recursive: true }); -}); +import type { ScheduleSummary } from "@rakkr/shared"; +import type { AppBindings } from "../src/http-types.js"; +import { + allowPermission, + createAuditStore, + createNodeStore, + createSettingsStore, + denyMissingPermission, + node, + recordAuditEvent, + recordingStore, + registerScheduleRoutes, + requestJson, + schedule, + scheduleList, + scheduleStore, + user, +} from "./schedule-routes-harness.js"; test("schedule routes deny users without required permissions", async () => { const auditStore = createAuditStore(""); @@ -337,659 +328,3 @@ test("schedule detail route returns scoped schedules only", async () => { "sched_missing:schedule_not_found", ]); }); - -test("schedule occurrence and lifecycle routes only operate on scoped schedules", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = user(["schedule:read", "schedule:manage"]); - const visible = schedule({ id: "sched_visible_lifecycle", name: "Visible Lifecycle" }); - const hidden = schedule({ id: "sched_hidden_lifecycle", name: "Hidden Lifecycle" }); - const store = scheduleStore([visible, hidden]); - const recordings = recordingStore(); - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([node()]), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore: recordings, - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [node()], - scopedSchedules: async () => [visible], - settingsStore: createSettingsStore(), - }); - - const occurrences = await app.request(`/api/v1/schedules/${hidden.id}/occurrences`); - const update = await requestJson(app, `/api/v1/schedules/${hidden.id}`, "PATCH", { - name: "Hidden Mutated", - }); - const runNow = await app.request(`/api/v1/schedules/${hidden.id}/run-now`, { method: "POST" }); - const skipNext = await app.request(`/api/v1/schedules/${hidden.id}/skip-next`, { - method: "POST", - }); - const deleted = await app.request(`/api/v1/schedules/${hidden.id}`, { method: "DELETE" }); - const stillHidden = await store.find(hidden.id); - const recordingList = await recordings.list(); - const failedEvents = await auditStore.list({ outcome: "failed" }); - const readFailures = failedEvents.filter((event) => event.permission === "schedule:read"); - const manageFailures = failedEvents.filter((event) => event.permission === "schedule:manage"); - - assert.deepEqual( - [occurrences.status, update.status, runNow.status, skipNext.status, deleted.status], - [404, 404, 404, 404, 404], - ); - assert.equal(stillHidden?.name, hidden.name); - assert.deepEqual(recordingList, []); - assert.deepEqual( - readFailures.map((event) => `${event.action}:${event.reason}`), - ["schedules.occurrences.read.failed:schedule_not_found"], - ); - assert.deepEqual(manageFailures.map((event) => `${event.action}:${event.reason}`).sort(), [ - "schedules.delete.failed:schedule_not_found", - "schedules.run_now.failed:schedule_not_found", - "schedules.skip_next.failed:schedule_not_found", - "schedules.update.failed:schedule_not_found", - ]); -}); - -test("schedule action summary returns scoped readiness links and node context", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = user(["schedule:read", "schedule:manage"]); - const visible = schedule({ - id: `sched_actions_visible_${randomUUID()}`, - name: "Visible Actions", - nodeId: "node_schedule_actions", - }); - const hidden = schedule({ id: `sched_actions_hidden_${randomUUID()}` }); - const store = scheduleStore([visible, hidden]); - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([node({ id: visible.nodeId })]), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore: recordingStore(), - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [node({ id: visible.nodeId })], - scopedSchedules: async () => [visible], - settingsStore: createSettingsStore(), - }); - - const visibleResponse = await app.request(`/api/v1/schedules/${visible.id}/actions`); - const hiddenResponse = await app.request(`/api/v1/schedules/${hidden.id}/actions`); - const body = (await visibleResponse.json()) as ScheduleActionsResponse; - const [succeededEvent] = await auditStore.list({ - action: "schedules.actions.read.succeeded", - }); - const [failedEvent] = await auditStore.list({ action: "schedules.actions.read.failed" }); - - assert.equal(visibleResponse.status, 200); - assert.equal(hiddenResponse.status, 404); - assert.equal(body.data.schedule.id, visible.id); - assert.equal(body.data.node?.id, visible.nodeId); - assert.equal(body.data.actions.edit.enabled, true); - assert.equal(body.data.actions.delete.enabled, true); - assert.equal(body.data.actions.runNow.enabled, true); - assert.equal(body.data.actions.skipNext.enabled, true); - assert.equal(body.data.actions.occurrences.enabled, true); - assert.equal(body.data.links.runNow, `/api/v1/schedules/${visible.id}/run-now`); - assert.equal(body.data.links.occurrences, `/api/v1/schedules/${visible.id}/occurrences`); - assert.equal(succeededEvent?.outcome, "succeeded"); - assert.equal(succeededEvent?.permission, "schedule:read"); - assert.equal(succeededEvent?.target.id, visible.id); - assert.equal(succeededEvent?.target.name, visible.name); - assert.equal(succeededEvent?.details.nodeAvailable, true); - assert.equal(succeededEvent?.details.scheduleEnabled, true); - assert.equal(succeededEvent?.details.visibleActionCount, 5); - assert.equal(failedEvent?.outcome, "failed"); - assert.equal(failedEvent?.permission, "schedule:read"); - assert.equal(failedEvent?.reason, "schedule_not_found"); - assert.equal(failedEvent?.target.id, hidden.id); -}); - -test("schedule action summary reports lifecycle blockers for disabled schedules", async () => { - const app = new Hono(); - const currentUser = user(["schedule:read", "schedule:manage"]); - const disabled = schedule({ - enabled: false, - id: `sched_actions_disabled_${randomUUID()}`, - name: "Disabled Actions", - }); - const store = scheduleStore([disabled]); - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([node()]), - recordAuditEvent: recordAuditEvent(createAuditStore("")), - recordingStore: recordingStore(), - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [node()], - scopedSchedules: async () => [disabled], - settingsStore: createSettingsStore(), - }); - - const response = await app.request(`/api/v1/schedules/${disabled.id}/actions`); - const body = (await response.json()) as ScheduleActionsResponse; - - assert.equal(response.status, 200); - assert.equal(body.data.actions.runNow.enabled, false); - assert.equal(body.data.actions.runNow.reason, "schedule_disabled"); - assert.equal(body.data.actions.skipNext.enabled, false); - assert.equal(body.data.actions.skipNext.reason, "schedule_disabled"); -}); - -test("schedule action summary hides out-of-scope node context and readiness", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = user(["schedule:read", "schedule:manage"]); - const visibleNode = node({ id: "node_schedule_action_visible" }); - const hiddenNode = node({ id: "node_schedule_action_hidden" }); - const scheduleOnHiddenNode = schedule({ - id: "sched_action_hidden_node", - name: "Hidden Node Action Summary", - nodeId: hiddenNode.id, - }); - const store = scheduleStore([scheduleOnHiddenNode]); - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([visibleNode, hiddenNode]), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore: recordingStore(), - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [visibleNode], - scopedSchedules: async () => [scheduleOnHiddenNode], - settingsStore: createSettingsStore(), - }); - - const response = await app.request(`/api/v1/schedules/${scheduleOnHiddenNode.id}/actions`); - const body = (await response.json()) as ScheduleActionsResponse; - const [event] = await auditStore.list({ action: "schedules.actions.read.succeeded" }); - - assert.equal(response.status, 200); - assert.equal(body.data.schedule.id, scheduleOnHiddenNode.id); - assert.equal(body.data.node, undefined); - assert.equal(body.data.actions.runNow.enabled, false); - assert.equal(body.data.actions.runNow.reason, "schedule_node_not_found"); - assert.equal(body.data.actions.skipNext.enabled, true); - assert.equal(event?.details.nodeAvailable, false); - assert.equal(event?.target.id, scheduleOnHiddenNode.id); -}); - -test("disabled schedule run-now is rejected and audited", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = user(["schedule:manage", "schedule:read"]); - const disabled = schedule({ - enabled: false, - id: `sched_disabled_run_${randomUUID()}`, - nodeId: node().id, - }); - const store = scheduleStore([disabled]); - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([node()]), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore: recordingStore(), - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [node()], - scopedSchedules: async () => [disabled], - settingsStore: createSettingsStore(), - }); - - const response = await app.request(`/api/v1/schedules/${disabled.id}/run-now`, { - method: "POST", - }); - const [event] = await auditStore.list({ action: "schedules.run_now.failed" }); - - assert.equal(response.status, 409); - assert.equal(event?.reason, "schedule_disabled"); - assert.equal(event?.target.id, disabled.id); -}); - -test("schedule work routes only operate on scoped visible nodes", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = user(["schedule:manage", "schedule:read"]); - const visibleNode = node({ id: "node_schedule_visible" }); - const hiddenNode = node({ id: "node_schedule_hidden" }); - const visible = schedule({ - id: "sched_visible_node_scope", - nodeId: visibleNode.id, - }); - const hiddenNodeSchedule = schedule({ - id: "sched_hidden_node_scope", - nodeId: hiddenNode.id, - }); - const store = scheduleStore([visible, hiddenNodeSchedule]); - const recordings = recordingStore(); - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([visibleNode, hiddenNode]), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore: recordings, - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [visibleNode], - scopedSchedules: () => store.list(), - settingsStore: createSettingsStore(), - }); - - const createHidden = await requestJson(app, "/api/v1/schedules", "POST", { - enabled: true, - folderTemplate: "Hidden/{{date}}", - name: "Hidden Node Schedule", - nodeId: hiddenNode.id, - recordingProfileId: "voice-mp3-vbr", - room: "Hidden Room", - timezone: "UTC", - titleTemplate: "{{date}} Hidden Node Schedule", - watchdogPolicyId: "scheduled-voice-watchdog", - }); - const updateHidden = await requestJson(app, `/api/v1/schedules/${visible.id}`, "PATCH", { - nodeId: hiddenNode.id, - }); - const runHidden = await app.request(`/api/v1/schedules/${hiddenNodeSchedule.id}/run-now`, { - method: "POST", - }); - const failedEvents = await auditStore.list({ - outcome: "failed", - permission: "schedule:manage", - }); - - assert.equal(createHidden.status, 409); - assert.equal(updateHidden.status, 409); - assert.equal(runHidden.status, 409); - assert.equal((await store.find(visible.id))?.nodeId, visibleNode.id); - assert.equal( - (await store.list()).some((candidate) => candidate.name === "Hidden Node Schedule"), - false, - ); - assert.equal((await recordings.list()).length, 0); - assert.deepEqual(failedEvents.map((event) => `${event.action}:${event.reason}`).sort(), [ - "schedules.create.failed:schedule_node_not_found", - "schedules.run_now.failed:schedule_node_not_found", - "schedules.update.failed:schedule_node_not_found", - ]); - assert.equal( - failedEvents.find((event) => event.action === "schedules.run_now.failed")?.target.id, - hiddenNodeSchedule.id, - ); -}); - -test("schedule routes create update run-now and skip-next with audit events", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = user(["recording:read", "schedule:read", "schedule:manage"]); - const routeInterfaceId = "iface_route_jack"; - const routeNode = node({ - id: `node_schedule_ops_${randomUUID()}`, - interfaces: [ - { - alias: "JACK Route", - backend: "jack", - channelCount: 2, - channels: [ - { alias: "Left", index: 1 }, - { alias: "Right", index: 2 }, - ], - id: routeInterfaceId, - sampleRates: [48_000], - systemName: "jack:route", - systemRef: "jack:route", - }, - ], - }); - const schedules: ScheduleSummary[] = []; - const store = scheduleStore(schedules); - const recordings = recordingStore(); - const scheduleId = `sched_route_ops_${randomUUID()}`; - - registerScheduleRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - currentUser: () => currentUser, - nodeStore: createNodeStore([routeNode]), - recordAuditEvent: recordAuditEvent(auditStore), - recordingStore: recordings, - requirePermission: allowPermission(), - scheduleStore: store, - scopedNodes: async () => [routeNode], - scopedSchedules: () => store.list(), - settingsStore: createSettingsStore(), - }); - - const invalidInterface = await requestJson(app, "/api/v1/schedules", "POST", { - enabled: true, - captureInterfaceId: "missing_interface", - folderTemplate: "Meetings/{{date}}/{{schedule.name}}", - name: "Invalid Interface", - nodeId: routeNode.id, - recordingProfileId: "voice-mp3-vbr", - room: "Council Room", - timezone: "UTC", - titleTemplate: "{{date}}_{{time}}_{{schedule.name}}", - watchdogPolicyId: "scheduled-voice-watchdog", - }); - const created = await requestJson(app, "/api/v1/schedules", "POST", { - captureBackend: "jack", - captureInterfaceId: routeInterfaceId, - enabled: true, - folderTemplate: "Meetings/{{date}}/{{schedule.name}}", - id: scheduleId, - name: "Council Route Test", - nodeId: routeNode.id, - recurrence: { - endTime: "10:00", - interval: 1, - mode: "daily", - startEarlySeconds: 300, - startTime: "09:00", - stopLateSeconds: 120, - }, - recordingProfileId: "voice-mp3-vbr", - retentionPolicyId: "retention-keep-controller-cache", - room: "Council Room", - tags: ["voice", "route", "voice"], - timezone: "UTC", - titleTemplate: "{{date}}_{{time}}_{{schedule.name}}", - uploadPolicyIds: ["upload-policy-stub"], - watchdogPolicyId: "scheduled-voice-watchdog", - }); - const createdBody = (await created.json()) as { data: ScheduleSummary }; - const updated = await requestJson(app, `/api/v1/schedules/${scheduleId}`, "PATCH", { - name: "Council Route Test Updated", - tags: ["updated", "voice", "updated"], - }); - const updatedBody = (await updated.json()) as { data: ScheduleSummary }; - const occurrences = await app.request(`/api/v1/schedules/${scheduleId}/occurrences?limit=2`); - const runNow = await app.request(`/api/v1/schedules/${scheduleId}/run-now`, { method: "POST" }); - const runNowBody = (await runNow.json()) as { - data: RecordingSummary; - job: { - command: { captureBackend?: string; captureDevice: string; captureInterfaceId?: string }; - recordingId: string; - }; - segments: Array<{ recordingId: string }>; - }; - const beforeSkip = await store.find(scheduleId); - const skipped = await app.request(`/api/v1/schedules/${scheduleId}/skip-next`, { - method: "POST", - }); - const skippedBody = (await skipped.json()) as { data: ScheduleSummary }; - const succeededAudits = await auditStore.list({ - outcome: "succeeded", - permission: "schedule:manage", - }); - const runNowAudit = succeededAudits.find( - (event) => event.action === "schedules.run_now.succeeded", - ); - const [occurrencesAudit] = await auditStore.list({ - action: "schedules.occurrences.read.succeeded", - }); - - assert.equal(invalidInterface.status, 409); - assert.equal(created.status, 201); - assert.equal(updated.status, 200); - assert.equal(occurrences.status, 200); - assert.equal(runNow.status, 202); - assert.equal(skipped.status, 200); - assert.equal(createdBody.data.id, scheduleId); - assert.equal(createdBody.data.captureBackend, "jack"); - assert.equal(createdBody.data.captureInterfaceId, routeInterfaceId); - assert.deepEqual(createdBody.data.tags, ["voice", "route"]); - assert.equal(updatedBody.data.name, "Council Route Test Updated"); - assert.deepEqual(updatedBody.data.tags, ["updated", "voice"]); - assert.equal(runNowBody.data.scheduleId, scheduleId); - assert.equal(runNowBody.data.retentionPolicyId, "retention-keep-controller-cache"); - assert.equal(runNowBody.job.command.captureBackend, "jack"); - assert.equal(runNowBody.job.command.captureDevice, "jack:route"); - assert.equal(runNowBody.job.command.captureInterfaceId, routeInterfaceId); - assert.equal(runNowBody.job.recordingId, runNowBody.data.id); - assert.deepEqual( - runNowBody.segments.map((segment) => segment.recordingId), - [runNowBody.data.id], - ); - assert.equal((await recordings.list())[0]?.source, "schedule"); - assert.notEqual(skippedBody.data.nextRunAt, beforeSkip?.nextRunAt); - assert.ok( - skippedBody.data.recurrence.exceptions?.some((exception) => exception.action === "skip"), - ); - assert.deepEqual(succeededAudits.map((event) => event.action).sort(), [ - "schedules.create.succeeded", - "schedules.run_now.succeeded", - "schedules.skip_next.succeeded", - "schedules.update.succeeded", - ]); - assert.equal(runNowAudit?.correlationIds?.scheduleId, scheduleId); - assert.equal(runNowAudit?.details.captureBackend, "jack"); - assert.equal(runNowAudit?.details.captureInterfaceId, routeInterfaceId); - assert.equal(runNowAudit?.after?.recordingId, runNowBody.data.id); - assert.equal(occurrencesAudit?.permission, "schedule:read"); - assert.equal(occurrencesAudit?.target.id, scheduleId); - assert.equal(occurrencesAudit?.details.requestedLimit, 2); - assert.equal(occurrencesAudit?.details.occurrenceCount, 2); -}); - -function requestJson( - app: Hono, - path: string, - method: "PATCH" | "POST", - body: Record, -) { - return app.request(path, { - body: JSON.stringify(body), - headers: { "content-type": "application/json" }, - method, - }); -} - -async function scheduleList(app: Hono, query = "") { - const response = await app.request(`/api/v1/schedules${query}`); - const body = (await response.json()) as { data: ScheduleSummary[] }; - - assert.equal(response.status, 200); - - return body.data; -} - -interface ScheduleActionsResponse { - data: { - actions: Record; - links: Record; - node?: RecorderNode; - schedule: ScheduleSummary; - }; -} - -function allowPermission(): RequirePermission { - return () => async (_c, next) => { - await next(); - }; -} - -function denyMissingPermission( - auditStore: ReturnType, - currentUser: CurrentUser, -): RequirePermission { - return (permission, action, target) => async (c) => { - const auditTarget = target ? await target(c) : { type: "controller" as const }; - - await recordAuditEvent(auditStore)(c, { - action, - auth: { user: currentUser }, - details: { - requiredPermission: permission, - resourceScope: auditTarget, - roles: currentUser.roles, - }, - outcome: "denied", - permission, - reason: "missing_permission", - target: auditTarget, - }); - - return c.json({ error: "Forbidden", permission }, 403); - }; -} - -function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { - return async (_c, input) => { - const actor = input.actor ?? { - id: input.auth?.user?.id ?? "anonymous", - name: input.auth?.user?.name ?? "Anonymous", - roles: input.auth?.user?.roles ?? [], - type: "user" as const, - }; - const event: AuditEvent = { - action: input.action, - actor, - actorContext: {}, - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: input.details ?? {}, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; - }; -} - -function scheduleStore(schedules: ScheduleSummary[]): ScheduleStore { - return { - async create(schedule) { - schedules.unshift(schedule); - - return schedule; - }, - async delete(scheduleId) { - const index = schedules.findIndex((candidate) => candidate.id === scheduleId); - const [deleted] = index >= 0 ? schedules.splice(index, 1) : []; - - return deleted; - }, - async find(scheduleId) { - return schedules.find((candidate) => candidate.id === scheduleId); - }, - async list() { - return schedules; - }, - async update(scheduleId, update) { - const index = schedules.findIndex((candidate) => candidate.id === scheduleId); - - if (index < 0) { - return undefined; - } - - schedules[index] = { ...schedules[index], ...update }; - - return schedules[index]; - }, - }; -} - -function recordingStore() { - const recordings: RecordingSummary[] = []; - - return { - async create(recording: RecordingSummary) { - recordings.unshift(recording); - }, - async delete(recordingId: string) { - const index = recordings.findIndex((candidate) => candidate.id === recordingId); - const [deleted] = index >= 0 ? recordings.splice(index, 1) : []; - - return deleted; - }, - async find(recordingId: string) { - return recordings.find((candidate) => candidate.id === recordingId); - }, - async list() { - return recordings; - }, - async save(recording: RecordingSummary) { - recordings.unshift(recording); - }, - }; -} - -function user(permissions: Permission[] = ["schedule:read"]): CurrentUser { - return { - email: "schedule-viewer@example.com", - groups: [], - id: "user_schedule_viewer_test", - name: "Schedule Viewer Test", - permissions, - provider: "local", - resourceGrants: [], - roles: ["viewer"], - }; -} - -function node(input: Partial = {}): RecorderNode { - return { - agentVersion: "0.1.0", - alias: "Schedule Node", - hostname: "schedule-node", - id: "node_schedule_test", - interfaces: [], - ipAddresses: ["10.0.0.60"], - lastSeenAt: "2026-06-18T12:00:00.000Z", - location: { - room: "Council Room", - site: "Main Site", - }, - status: "online", - tags: ["voice"], - ...input, - }; -} - -function schedule(input: Partial = {}): ScheduleSummary { - return { - assignedGroupIds: [], - assignedUserIds: [], - enabled: true, - folderTemplate: "meetings/{{date}}", - id: "sched_route_test", - name: "Council Meeting", - nextRunAt: "2026-06-18T09:00:00.000Z", - nodeId: node().id, - recurrence: { mode: "manual" }, - recordingProfileId: "voice-mp3-vbr", - retentionPolicyId: "retention-keep-controller-cache", - room: "Council Room", - tags: ["council"], - timezone: "UTC", - titleTemplate: "{{date}} Council Meeting", - uploadPolicyIds: ["upload-policy-stub"], - watchdogPolicyId: "scheduled-voice-watchdog", - ...input, - }; -} diff --git a/apps/api/test/settings-routes-harness.ts b/apps/api/test/settings-routes-harness.ts new file mode 100644 index 0000000..25d5563 --- /dev/null +++ b/apps/api/test/settings-routes-harness.ts @@ -0,0 +1,207 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { Hono } from "hono"; +import type { AuditEvent, CurrentUser } from "@rakkr/shared"; +import type { + AppBindings, + AuditTarget, + RecordAuditEvent, + RequirePermission, +} from "../src/http-types.js"; + +// Shared harness for the settings route tests. Extracted from +// settings-routes.test.ts to keep each test file under the 1000-LOC guard. +// +// The store modules read their RAKKR_*_STORE_PATH env vars at import time, so +// the env setup below MUST run before the dynamic imports. + +const settingsRoot = await mkdtemp(path.join(tmpdir(), "rakkr-settings-routes-")); +process.env.DATABASE_URL = ""; +process.env.RAKKR_CHANNEL_MAP_ASSIGNMENT_STORE_PATH = path.join( + settingsRoot, + "channel-map-assignments.json", +); +process.env.RAKKR_CHANNEL_MAP_ASSIGNMENT_PLAN_STORE_PATH = path.join( + settingsRoot, + "channel-map-assignment-plans.json", +); +process.env.RAKKR_CHANNEL_MAP_TEMPLATE_STORE_PATH = path.join( + settingsRoot, + "channel-map-templates.json", +); +process.env.RAKKR_RECORDING_PROFILE_STORE_PATH = path.join(settingsRoot, "profiles.json"); +process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(settingsRoot, "upload-policies.json"); +process.env.RAKKR_UPLOAD_DESTINATION_STORE_PATH = path.join(settingsRoot, "upload-providers.json"); +process.env.RAKKR_WATCHDOG_POLICY_STORE_PATH = path.join(settingsRoot, "watchdog-policies.json"); + +const { createAuditStore } = await import("../src/audit-store.js"); +const { createChannelMapAssignmentPlanStore } = + await import("../src/channel-map-assignment-plans.js"); +const { registerSettingsRoutes } = await import("../src/settings-routes.js"); +const { createSettingsStore } = await import("../src/settings-store.js"); +const { createUploadPolicy } = await import("../src/upload-policies.js"); +const { createUploadDestinationStore } = await import("../src/upload-destinations.js"); + +export { + createAuditStore, + createChannelMapAssignmentPlanStore, + createSettingsStore, + createUploadDestinationStore, + createUploadPolicy, + registerSettingsRoutes, +}; + +test.after(async () => { + await rm(settingsRoot, { force: true, recursive: true }); +}); + +export function requestJson( + app: Hono, + path: string, + method: "PATCH" | "POST" | "PUT", + body: Record, +) { + return app.request(path, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method, + }); +} + +export async function jsonData(app: Hono, path: string) { + const response = await app.request(path); + const body = (await response.json()) as { data: Record }; + + assert.equal(response.status, 200); + + return body.data; +} + +export function allowPermission(): RequirePermission { + return () => async (_c, next) => { + await next(); + }; +} + +export function denyMissingPermission( + auditStore: ReturnType, + currentUser: CurrentUser, +): RequirePermission { + return (permission, action, target) => async (c) => { + const auditTarget = target ? await target(c) : { type: "controller" as const }; + + await recordAuditEvent(auditStore)(c, { + action, + auth: { user: currentUser }, + details: { + requiredPermission: permission, + resourceScope: auditTarget, + roles: currentUser.roles, + }, + outcome: "denied", + permission, + reason: "missing_permission", + target: auditTarget, + }); + + return c.json({ error: "Forbidden", permission }, 403); + }; +} + +export function denyResourceScope( + auditStore: ReturnType, + currentUser: CurrentUser, + isVisibleTarget: (target: AuditTarget) => boolean, +): RequirePermission { + return (permission, action, target) => async (c, next) => { + const auditTarget = target ? await target(c) : { type: "controller" as const }; + const hasPermission = currentUser.permissions.includes(permission); + const allowed = hasPermission && isVisibleTarget(auditTarget); + + await recordAuditEvent(auditStore)(c, { + action, + auth: { user: currentUser }, + details: { + requiredPermission: permission, + resourceScope: auditTarget, + roles: currentUser.roles, + }, + outcome: allowed ? "allowed" : "denied", + permission, + reason: hasPermission ? "access_policy_denied" : "missing_permission", + target: auditTarget, + }); + + if (!allowed) { + return c.json({ error: "Forbidden", permission }, 403); + } + + await next(); + }; +} + +export function recordAuditEvent( + auditStore: ReturnType, +): RecordAuditEvent { + return async (_c, input) => { + const actor = input.actor ?? { + id: input.auth?.user?.id ?? "anonymous", + name: input.auth?.user?.name ?? "Anonymous", + roles: input.auth?.user?.roles ?? [], + type: "user" as const, + }; + const event: AuditEvent = { + action: input.action, + actor, + actorContext: {}, + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: input.details ?? {}, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; +} + +export function viewer(permissions = ["settings:read"]): CurrentUser { + return { + email: "settings-viewer@example.com", + groups: [], + id: "user_settings_viewer_test", + name: "Settings Viewer Test", + permissions, + provider: "local", + resourceGrants: [], + roles: ["viewer"], + }; +} + +export function channelMapInput(id: string, name: string) { + return { + channelMode: "mono_to_stereo_mix", + entries: [ + { + included: true, + label: "Podium Mic", + outputChannelIndex: 1, + sourceChannelIndex: 1, + }, + ], + id, + name, + tags: ["voice"], + }; +} diff --git a/apps/api/test/settings-routes-mutations.test.ts b/apps/api/test/settings-routes-mutations.test.ts new file mode 100644 index 0000000..b2ef605 --- /dev/null +++ b/apps/api/test/settings-routes-mutations.test.ts @@ -0,0 +1,397 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Hono } from "hono"; +import type { AppBindings } from "../src/http-types.js"; +import { + allowPermission, + channelMapInput, + createAuditStore, + createChannelMapAssignmentPlanStore, + createSettingsStore, + createUploadDestinationStore, + createUploadPolicy, + jsonData, + recordAuditEvent, + registerSettingsRoutes, + requestJson, + viewer, +} from "./settings-routes-harness.js"; + +test("settings detail routes return individual settings resources", async () => { + const app = new Hono(); + const currentUser = viewer(["settings:read"]); + const settingsStore = createSettingsStore(); + const channelMapAssignmentPlanStore = createChannelMapAssignmentPlanStore(); + const uploadDestinationStore = createUploadDestinationStore(); + const template = await settingsStore.createChannelMapTemplate({ + channelMode: "mono_to_stereo_mix", + entries: [ + { + included: true, + label: "Detail Channel", + outputChannelIndex: 1, + sourceChannelIndex: 1, + }, + ], + id: `channel_map_detail_${randomUUID()}`, + name: "Detail Channel Map", + tags: ["detail"], + }); + const plan = await channelMapAssignmentPlanStore.create({ + targets: [{ targetId: "node_detail", targetType: "node" }], + templateId: template.id, + }); + const uploadPolicy = await createUploadPolicy({ + enabled: true, + id: `upload-policy-detail-${randomUUID()}`, + maxAttempts: 3, + name: "Detail Upload Policy", + trigger: "manual", + }); + const uploadDestination = await uploadDestinationStore.create({ + displayName: "Detail Destination", + enabled: true, + kind: "smb", + smb: { server: "files.example.lan", share: "recordings", username: "svc" }, + smbPassword: "s3cr3t", + }); + + registerSettingsRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + channelMapAssignmentPlanStore, + recordAuditEvent: recordAuditEvent(createAuditStore("")), + requirePermission: allowPermission(), + settingsStore, + uploadDestinationStore, + }); + + const profile = await jsonData(app, "/api/v1/settings/recording-profiles/voice-mp3-vbr"); + const watchdog = await jsonData( + app, + "/api/v1/settings/watchdog-policies/scheduled-voice-watchdog", + ); + const templateDetail = await jsonData( + app, + `/api/v1/settings/channel-map-templates/${template.id}`, + ); + const planDetail = await jsonData( + app, + `/api/v1/settings/channel-map-assignment-plans/${plan.id}`, + ); + const destination = await jsonData( + app, + `/api/v1/settings/upload-destinations/${uploadDestination.id}`, + ); + const policy = await jsonData(app, `/api/v1/settings/upload-policies/${uploadPolicy.id}`); + const missingProfile = await app.request("/api/v1/settings/recording-profiles/profile_missing"); + const missingTemplate = await app.request( + "/api/v1/settings/channel-map-templates/template_missing", + ); + const missingDestination = await app.request( + "/api/v1/settings/upload-destinations/destination_missing", + ); + + assert.equal(profile.id, "voice-mp3-vbr"); + assert.equal(watchdog.id, "scheduled-voice-watchdog"); + assert.equal(templateDetail.id, template.id); + assert.equal(planDetail.id, plan.id); + assert.equal(destination.id, uploadDestination.id); + assert.equal(policy.id, uploadPolicy.id); + assert.equal(missingProfile.status, 404); + assert.equal(missingTemplate.status, 404); + assert.equal(missingDestination.status, 404); +}); + +test("settings manage routes update operational templates and audit snapshots", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const currentUser = viewer(["settings:read", "settings:manage"]); + const settingsStore = createSettingsStore(); + const channelMapAssignmentPlanStore = createChannelMapAssignmentPlanStore(); + const uploadDestinationStore = createUploadDestinationStore(); + const primaryTemplateId = `channel_map_ops_${randomUUID()}`; + const rollbackTemplateId = `channel_map_rollback_${randomUUID()}`; + const uploadPolicyId = `upload-policy-ops-${randomUUID()}`; + const uploadDestination = await uploadDestinationStore.create({ + displayName: "Operations Destination", + enabled: true, + kind: "smb", + smb: { server: "ops.lan", share: "recordings", username: "svc" }, + smbPassword: "s3cr3t", + }); + + registerSettingsRoutes({ + app, + currentAuth: () => ({ user: currentUser }), + channelMapAssignmentPlanStore, + recordAuditEvent: recordAuditEvent(auditStore), + requirePermission: allowPermission(), + settingsStore, + uploadDestinationStore, + }); + + const profileResponse = await requestJson( + app, + "/api/v1/settings/recording-profiles/voice-mp3-vbr", + "PATCH", + { + maxTrackSeconds: 900, + name: "Operations Voice MP3", + }, + ); + const watchdogResponse = await requestJson( + app, + "/api/v1/settings/watchdog-policies/scheduled-voice-watchdog", + "PATCH", + { + broadbandNoiseScoreThreshold: 0.84, + channelCorrelationMode: "alert_on_high", + channelCorrelationThreshold: 0.97, + clippingMode: "alert_on_clipping", + flatlineMode: "alert_on_flatline", + flatlineThresholdDbfs: -105, + humScoreThreshold: 0.76, + minCumulativeChannelCorrelationSeconds: 15, + minCumulativeClippingSeconds: 2, + minCumulativeFlatlineSeconds: 12, + minCumulativeQualitySeconds: 18, + minSpeechScore: 0.65, + name: "Operations Voice Watchdog", + noiseScoreThreshold: 0.88, + qualityAlertMode: "alert_on_noise_hum_static", + qualityMode: "speech_required", + staticScoreThreshold: 0.79, + }, + ); + const uploadCreateResponse = await requestJson(app, "/api/v1/settings/upload-policies", "POST", { + destinationId: uploadDestination.id, + enabled: true, + id: uploadPolicyId, + maxAttempts: 4, + name: "Operations Upload", + trigger: "manual", + }); + const uploadUpdateResponse = await requestJson( + app, + `/api/v1/settings/upload-policies/${uploadPolicyId}`, + "PATCH", + { + deleteCacheAfterUpload: true, + maxAttempts: 6, + }, + ); + const templateCreateResponse = await requestJson( + app, + "/api/v1/settings/channel-map-templates", + "POST", + channelMapInput(primaryTemplateId, "Operations Primary Map"), + ); + const templateUpdateResponse = await requestJson( + app, + `/api/v1/settings/channel-map-templates/${primaryTemplateId}`, + "PATCH", + { + name: "Operations Primary Map Rev 2", + tags: ["voice", "ops"], + }, + ); + await requestJson( + app, + "/api/v1/settings/channel-map-templates", + "POST", + channelMapInput(rollbackTemplateId, "Operations Rollback Map"), + ); + const assignmentResponse = await requestJson( + app, + "/api/v1/settings/channel-map-assignments", + "PUT", + { + targetId: "node_ops_room", + targetType: "node", + templateId: primaryTemplateId, + }, + ); + const reassignmentResponse = await requestJson( + app, + "/api/v1/settings/channel-map-assignments", + "PUT", + { + targetId: "node_ops_room", + targetType: "node", + templateId: rollbackTemplateId, + }, + ); + const bulkAssignmentResponse = await requestJson( + app, + "/api/v1/settings/channel-map-assignments/bulk", + "PUT", + { + targets: [ + { targetId: "node_ops_bulk", targetType: "node" }, + { targetId: "interface_ops_1", targetType: "interface" }, + { targetId: "interface_ops_1", targetType: "interface" }, + ], + templateId: rollbackTemplateId, + }, + ); + const planCreateResponse = await requestJson( + app, + "/api/v1/settings/channel-map-assignment-plans", + "POST", + { + note: "Stage council room rollout", + targets: [ + { targetId: "node_plan_room", targetType: "node" }, + { targetId: "interface_plan_1", targetType: "interface" }, + { targetId: "interface_plan_1", targetType: "interface" }, + ], + templateId: primaryTemplateId, + }, + ); + const planCreateBody = (await planCreateResponse.json()) as { + data: { id: string; status: string; targets: Array<{ targetId: string }> }; + }; + const planApplyResponse = await app.request( + `/api/v1/settings/channel-map-assignment-plans/${planCreateBody.data.id}/apply`, + { + method: "POST", + }, + ); + const planApplyAgainResponse = await app.request( + `/api/v1/settings/channel-map-assignment-plans/${planCreateBody.data.id}/apply`, + { + method: "POST", + }, + ); + const rollbackResponse = await requestJson( + app, + "/api/v1/settings/channel-map-assignments/rollback", + "POST", + { + targetId: "node_ops_room", + targetType: "node", + }, + ); + await requestJson(app, "/api/v1/settings/recording-profiles", "POST", { + name: "Created Recording Profile", + }); + await requestJson(app, "/api/v1/settings/watchdog-policies", "POST", { + name: "Created Watchdog Policy", + }); + const audits = await auditStore.list({ outcome: "succeeded", permission: "settings:manage" }); + + assert.equal(profileResponse.status, 200); + assert.equal(watchdogResponse.status, 200); + assert.equal(uploadCreateResponse.status, 201); + assert.equal(uploadUpdateResponse.status, 200); + assert.equal(templateCreateResponse.status, 201); + assert.equal(templateUpdateResponse.status, 200); + assert.equal(assignmentResponse.status, 200); + assert.equal(reassignmentResponse.status, 200); + assert.equal(bulkAssignmentResponse.status, 200); + assert.equal(planCreateResponse.status, 201); + assert.equal(planApplyResponse.status, 200); + assert.equal(planApplyAgainResponse.status, 409); + assert.equal(rollbackResponse.status, 200); + + const updatedTemplate = (await templateUpdateResponse.json()) as { data: { revision: number } }; + const reassignment = (await reassignmentResponse.json()) as { + data: { history: Array<{ previousTemplateId?: string }>; templateId: string }; + }; + const bulkAssignment = (await bulkAssignmentResponse.json()) as { + data: Array<{ targetId: string; targetType: string; templateId: string }>; + }; + const planApply = (await planApplyResponse.json()) as { + data: { + assignments: Array<{ targetId: string; targetType: string; templateId: string }>; + plan: { status: string }; + }; + }; + const rollback = (await rollbackResponse.json()) as { data: { templateId: string } }; + + assert.equal(updatedTemplate.data.revision, 2); + assert.equal(reassignment.data.templateId, rollbackTemplateId); + assert.equal(reassignment.data.history.at(-1)?.previousTemplateId, primaryTemplateId); + assert.deepEqual( + bulkAssignment.data.map((assignment) => `${assignment.targetType}:${assignment.targetId}`), + ["node:node_ops_bulk", "interface:interface_ops_1"], + ); + assert.ok( + bulkAssignment.data.every((assignment) => assignment.templateId === rollbackTemplateId), + ); + assert.equal(planCreateBody.data.status, "pending"); + assert.deepEqual( + planCreateBody.data.targets.map((target) => target.targetId), + ["node_plan_room", "interface_plan_1"], + ); + assert.equal(planApply.data.plan.status, "applied"); + assert.deepEqual( + planApply.data.assignments.map( + (assignment) => `${assignment.targetType}:${assignment.targetId}`, + ), + ["node:node_plan_room", "interface:interface_plan_1"], + ); + assert.ok( + planApply.data.assignments.every((assignment) => assignment.templateId === primaryTemplateId), + ); + assert.equal(rollback.data.templateId, primaryTemplateId); + assert.deepEqual(audits.map((event) => event.action).sort(), [ + "settings.channel_map_assignment_plans.apply.succeeded", + "settings.channel_map_assignment_plans.create.succeeded", + "settings.channel_map_assignments.bulk_update.succeeded", + "settings.channel_map_assignments.rollback.succeeded", + "settings.channel_map_assignments.update.succeeded", + "settings.channel_map_assignments.update.succeeded", + "settings.channel_map_templates.create.succeeded", + "settings.channel_map_templates.create.succeeded", + "settings.channel_map_templates.update.succeeded", + "settings.recording_profiles.create.succeeded", + "settings.recording_profiles.update.succeeded", + "settings.upload_policies.create.succeeded", + "settings.upload_policies.update.succeeded", + "settings.watchdog_policies.create.succeeded", + "settings.watchdog_policies.update.succeeded", + ]); + + const profileAudit = audits.find( + (event) => event.action === "settings.recording_profiles.update.succeeded", + ); + + assert.equal(profileAudit?.before?.name, "Voice MP3 VBR"); + assert.equal(profileAudit?.after?.name, "Operations Voice MP3"); + const watchdogAudit = audits.find( + (event) => + event.action === "settings.watchdog_policies.update.succeeded" && + event.after?.name === "Operations Voice Watchdog", + ); + + assert.equal(watchdogAudit?.after?.channelCorrelationMode, "alert_on_high"); + assert.equal(watchdogAudit?.after?.broadbandNoiseScoreThreshold, 0.84); + assert.equal(watchdogAudit?.after?.channelCorrelationThreshold, 0.97); + assert.equal(watchdogAudit?.after?.clippingMode, "alert_on_clipping"); + assert.equal(watchdogAudit?.after?.flatlineMode, "alert_on_flatline"); + assert.equal(watchdogAudit?.after?.flatlineThresholdDbfs, -105); + assert.equal(watchdogAudit?.after?.humScoreThreshold, 0.76); + assert.equal(watchdogAudit?.after?.minCumulativeChannelCorrelationSeconds, 15); + assert.equal(watchdogAudit?.after?.minCumulativeClippingSeconds, 2); + assert.equal(watchdogAudit?.after?.minCumulativeFlatlineSeconds, 12); + assert.equal(watchdogAudit?.after?.minCumulativeQualitySeconds, 18); + assert.equal(watchdogAudit?.after?.noiseScoreThreshold, 0.88); + assert.equal(watchdogAudit?.after?.qualityAlertMode, "alert_on_noise_hum_static"); + assert.equal(watchdogAudit?.after?.staticScoreThreshold, 0.79); + const bulkAudit = audits.find( + (event) => event.action === "settings.channel_map_assignments.bulk_update.succeeded", + ); + + assert.equal(bulkAudit?.after?.targetCount, 2); + assert.equal(bulkAudit?.target.type, "channel_map_assignment_collection"); + const planApplyAudit = audits.find( + (event) => event.action === "settings.channel_map_assignment_plans.apply.succeeded", + ); + + assert.equal(planApplyAudit?.before?.status, "pending"); + assert.equal(planApplyAudit?.after?.plan.status, "applied"); + assert.equal(planApplyAudit?.after?.targetCount, 2); +}); diff --git a/apps/api/test/settings-routes.test.ts b/apps/api/test/settings-routes.test.ts index 0589daa..ebfe6f4 100644 --- a/apps/api/test/settings-routes.test.ts +++ b/apps/api/test/settings-routes.test.ts @@ -1,48 +1,19 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import test from "node:test"; import { Hono } from "hono"; -import type { AuditEvent, CurrentUser } from "@rakkr/shared"; -import type { - AppBindings, - AuditTarget, - RecordAuditEvent, - RequirePermission, -} from "../src/http-types.js"; - -const settingsRoot = await mkdtemp(path.join(tmpdir(), "rakkr-settings-routes-")); -process.env.DATABASE_URL = ""; -process.env.RAKKR_CHANNEL_MAP_ASSIGNMENT_STORE_PATH = path.join( - settingsRoot, - "channel-map-assignments.json", -); -process.env.RAKKR_CHANNEL_MAP_ASSIGNMENT_PLAN_STORE_PATH = path.join( - settingsRoot, - "channel-map-assignment-plans.json", -); -process.env.RAKKR_CHANNEL_MAP_TEMPLATE_STORE_PATH = path.join( - settingsRoot, - "channel-map-templates.json", -); -process.env.RAKKR_RECORDING_PROFILE_STORE_PATH = path.join(settingsRoot, "profiles.json"); -process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(settingsRoot, "upload-policies.json"); -process.env.RAKKR_UPLOAD_DESTINATION_STORE_PATH = path.join(settingsRoot, "upload-providers.json"); -process.env.RAKKR_WATCHDOG_POLICY_STORE_PATH = path.join(settingsRoot, "watchdog-policies.json"); - -const { createAuditStore } = await import("../src/audit-store.js"); -const { createChannelMapAssignmentPlanStore } = - await import("../src/channel-map-assignment-plans.js"); -const { registerSettingsRoutes } = await import("../src/settings-routes.js"); -const { createSettingsStore } = await import("../src/settings-store.js"); -const { createUploadPolicy } = await import("../src/upload-policies.js"); -const { createUploadDestinationStore } = await import("../src/upload-destinations.js"); - -test.after(async () => { - await rm(settingsRoot, { force: true, recursive: true }); -}); +import type { AppBindings, AuditTarget } from "../src/http-types.js"; +import { + createAuditStore, + createSettingsStore, + createUploadDestinationStore, + denyMissingPermission, + denyResourceScope, + recordAuditEvent, + registerSettingsRoutes, + requestJson, + viewer, +} from "./settings-routes-harness.js"; test("settings write routes deny users without settings manage", async () => { const app = new Hono(); @@ -474,526 +445,3 @@ test("channel map template routes honor resource-scope denies", async () => { ), ); }); - -test("settings detail routes return individual settings resources", async () => { - const app = new Hono(); - const currentUser = viewer(["settings:read"]); - const settingsStore = createSettingsStore(); - const channelMapAssignmentPlanStore = createChannelMapAssignmentPlanStore(); - const uploadDestinationStore = createUploadDestinationStore(); - const template = await settingsStore.createChannelMapTemplate({ - channelMode: "mono_to_stereo_mix", - entries: [ - { - included: true, - label: "Detail Channel", - outputChannelIndex: 1, - sourceChannelIndex: 1, - }, - ], - id: `channel_map_detail_${randomUUID()}`, - name: "Detail Channel Map", - tags: ["detail"], - }); - const plan = await channelMapAssignmentPlanStore.create({ - targets: [{ targetId: "node_detail", targetType: "node" }], - templateId: template.id, - }); - const uploadPolicy = await createUploadPolicy({ - enabled: true, - id: `upload-policy-detail-${randomUUID()}`, - maxAttempts: 3, - name: "Detail Upload Policy", - trigger: "manual", - }); - const uploadDestination = await uploadDestinationStore.create({ - displayName: "Detail Destination", - enabled: true, - kind: "smb", - smb: { server: "files.example.lan", share: "recordings", username: "svc" }, - smbPassword: "s3cr3t", - }); - - registerSettingsRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - channelMapAssignmentPlanStore, - recordAuditEvent: recordAuditEvent(createAuditStore("")), - requirePermission: allowPermission(), - settingsStore, - uploadDestinationStore, - }); - - const profile = await jsonData(app, "/api/v1/settings/recording-profiles/voice-mp3-vbr"); - const watchdog = await jsonData( - app, - "/api/v1/settings/watchdog-policies/scheduled-voice-watchdog", - ); - const templateDetail = await jsonData( - app, - `/api/v1/settings/channel-map-templates/${template.id}`, - ); - const planDetail = await jsonData( - app, - `/api/v1/settings/channel-map-assignment-plans/${plan.id}`, - ); - const destination = await jsonData( - app, - `/api/v1/settings/upload-destinations/${uploadDestination.id}`, - ); - const policy = await jsonData(app, `/api/v1/settings/upload-policies/${uploadPolicy.id}`); - const missingProfile = await app.request("/api/v1/settings/recording-profiles/profile_missing"); - const missingTemplate = await app.request( - "/api/v1/settings/channel-map-templates/template_missing", - ); - const missingDestination = await app.request( - "/api/v1/settings/upload-destinations/destination_missing", - ); - - assert.equal(profile.id, "voice-mp3-vbr"); - assert.equal(watchdog.id, "scheduled-voice-watchdog"); - assert.equal(templateDetail.id, template.id); - assert.equal(planDetail.id, plan.id); - assert.equal(destination.id, uploadDestination.id); - assert.equal(policy.id, uploadPolicy.id); - assert.equal(missingProfile.status, 404); - assert.equal(missingTemplate.status, 404); - assert.equal(missingDestination.status, 404); -}); - -test("settings manage routes update operational templates and audit snapshots", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const currentUser = viewer(["settings:read", "settings:manage"]); - const settingsStore = createSettingsStore(); - const channelMapAssignmentPlanStore = createChannelMapAssignmentPlanStore(); - const uploadDestinationStore = createUploadDestinationStore(); - const primaryTemplateId = `channel_map_ops_${randomUUID()}`; - const rollbackTemplateId = `channel_map_rollback_${randomUUID()}`; - const uploadPolicyId = `upload-policy-ops-${randomUUID()}`; - const uploadDestination = await uploadDestinationStore.create({ - displayName: "Operations Destination", - enabled: true, - kind: "smb", - smb: { server: "ops.lan", share: "recordings", username: "svc" }, - smbPassword: "s3cr3t", - }); - - registerSettingsRoutes({ - app, - currentAuth: () => ({ user: currentUser }), - channelMapAssignmentPlanStore, - recordAuditEvent: recordAuditEvent(auditStore), - requirePermission: allowPermission(), - settingsStore, - uploadDestinationStore, - }); - - const profileResponse = await requestJson( - app, - "/api/v1/settings/recording-profiles/voice-mp3-vbr", - "PATCH", - { - maxTrackSeconds: 900, - name: "Operations Voice MP3", - }, - ); - const watchdogResponse = await requestJson( - app, - "/api/v1/settings/watchdog-policies/scheduled-voice-watchdog", - "PATCH", - { - broadbandNoiseScoreThreshold: 0.84, - channelCorrelationMode: "alert_on_high", - channelCorrelationThreshold: 0.97, - clippingMode: "alert_on_clipping", - flatlineMode: "alert_on_flatline", - flatlineThresholdDbfs: -105, - humScoreThreshold: 0.76, - minCumulativeChannelCorrelationSeconds: 15, - minCumulativeClippingSeconds: 2, - minCumulativeFlatlineSeconds: 12, - minCumulativeQualitySeconds: 18, - minSpeechScore: 0.65, - name: "Operations Voice Watchdog", - noiseScoreThreshold: 0.88, - qualityAlertMode: "alert_on_noise_hum_static", - qualityMode: "speech_required", - staticScoreThreshold: 0.79, - }, - ); - const uploadCreateResponse = await requestJson(app, "/api/v1/settings/upload-policies", "POST", { - destinationId: uploadDestination.id, - enabled: true, - id: uploadPolicyId, - maxAttempts: 4, - name: "Operations Upload", - trigger: "manual", - }); - const uploadUpdateResponse = await requestJson( - app, - `/api/v1/settings/upload-policies/${uploadPolicyId}`, - "PATCH", - { - deleteCacheAfterUpload: true, - maxAttempts: 6, - }, - ); - const templateCreateResponse = await requestJson( - app, - "/api/v1/settings/channel-map-templates", - "POST", - channelMapInput(primaryTemplateId, "Operations Primary Map"), - ); - const templateUpdateResponse = await requestJson( - app, - `/api/v1/settings/channel-map-templates/${primaryTemplateId}`, - "PATCH", - { - name: "Operations Primary Map Rev 2", - tags: ["voice", "ops"], - }, - ); - await requestJson( - app, - "/api/v1/settings/channel-map-templates", - "POST", - channelMapInput(rollbackTemplateId, "Operations Rollback Map"), - ); - const assignmentResponse = await requestJson( - app, - "/api/v1/settings/channel-map-assignments", - "PUT", - { - targetId: "node_ops_room", - targetType: "node", - templateId: primaryTemplateId, - }, - ); - const reassignmentResponse = await requestJson( - app, - "/api/v1/settings/channel-map-assignments", - "PUT", - { - targetId: "node_ops_room", - targetType: "node", - templateId: rollbackTemplateId, - }, - ); - const bulkAssignmentResponse = await requestJson( - app, - "/api/v1/settings/channel-map-assignments/bulk", - "PUT", - { - targets: [ - { targetId: "node_ops_bulk", targetType: "node" }, - { targetId: "interface_ops_1", targetType: "interface" }, - { targetId: "interface_ops_1", targetType: "interface" }, - ], - templateId: rollbackTemplateId, - }, - ); - const planCreateResponse = await requestJson( - app, - "/api/v1/settings/channel-map-assignment-plans", - "POST", - { - note: "Stage council room rollout", - targets: [ - { targetId: "node_plan_room", targetType: "node" }, - { targetId: "interface_plan_1", targetType: "interface" }, - { targetId: "interface_plan_1", targetType: "interface" }, - ], - templateId: primaryTemplateId, - }, - ); - const planCreateBody = (await planCreateResponse.json()) as { - data: { id: string; status: string; targets: Array<{ targetId: string }> }; - }; - const planApplyResponse = await app.request( - `/api/v1/settings/channel-map-assignment-plans/${planCreateBody.data.id}/apply`, - { - method: "POST", - }, - ); - const planApplyAgainResponse = await app.request( - `/api/v1/settings/channel-map-assignment-plans/${planCreateBody.data.id}/apply`, - { - method: "POST", - }, - ); - const rollbackResponse = await requestJson( - app, - "/api/v1/settings/channel-map-assignments/rollback", - "POST", - { - targetId: "node_ops_room", - targetType: "node", - }, - ); - await requestJson(app, "/api/v1/settings/recording-profiles", "POST", { - name: "Created Recording Profile", - }); - await requestJson(app, "/api/v1/settings/watchdog-policies", "POST", { - name: "Created Watchdog Policy", - }); - const audits = await auditStore.list({ outcome: "succeeded", permission: "settings:manage" }); - - assert.equal(profileResponse.status, 200); - assert.equal(watchdogResponse.status, 200); - assert.equal(uploadCreateResponse.status, 201); - assert.equal(uploadUpdateResponse.status, 200); - assert.equal(templateCreateResponse.status, 201); - assert.equal(templateUpdateResponse.status, 200); - assert.equal(assignmentResponse.status, 200); - assert.equal(reassignmentResponse.status, 200); - assert.equal(bulkAssignmentResponse.status, 200); - assert.equal(planCreateResponse.status, 201); - assert.equal(planApplyResponse.status, 200); - assert.equal(planApplyAgainResponse.status, 409); - assert.equal(rollbackResponse.status, 200); - - const updatedTemplate = (await templateUpdateResponse.json()) as { data: { revision: number } }; - const reassignment = (await reassignmentResponse.json()) as { - data: { history: Array<{ previousTemplateId?: string }>; templateId: string }; - }; - const bulkAssignment = (await bulkAssignmentResponse.json()) as { - data: Array<{ targetId: string; targetType: string; templateId: string }>; - }; - const planApply = (await planApplyResponse.json()) as { - data: { - assignments: Array<{ targetId: string; targetType: string; templateId: string }>; - plan: { status: string }; - }; - }; - const rollback = (await rollbackResponse.json()) as { data: { templateId: string } }; - - assert.equal(updatedTemplate.data.revision, 2); - assert.equal(reassignment.data.templateId, rollbackTemplateId); - assert.equal(reassignment.data.history.at(-1)?.previousTemplateId, primaryTemplateId); - assert.deepEqual( - bulkAssignment.data.map((assignment) => `${assignment.targetType}:${assignment.targetId}`), - ["node:node_ops_bulk", "interface:interface_ops_1"], - ); - assert.ok( - bulkAssignment.data.every((assignment) => assignment.templateId === rollbackTemplateId), - ); - assert.equal(planCreateBody.data.status, "pending"); - assert.deepEqual( - planCreateBody.data.targets.map((target) => target.targetId), - ["node_plan_room", "interface_plan_1"], - ); - assert.equal(planApply.data.plan.status, "applied"); - assert.deepEqual( - planApply.data.assignments.map( - (assignment) => `${assignment.targetType}:${assignment.targetId}`, - ), - ["node:node_plan_room", "interface:interface_plan_1"], - ); - assert.ok( - planApply.data.assignments.every((assignment) => assignment.templateId === primaryTemplateId), - ); - assert.equal(rollback.data.templateId, primaryTemplateId); - assert.deepEqual(audits.map((event) => event.action).sort(), [ - "settings.channel_map_assignment_plans.apply.succeeded", - "settings.channel_map_assignment_plans.create.succeeded", - "settings.channel_map_assignments.bulk_update.succeeded", - "settings.channel_map_assignments.rollback.succeeded", - "settings.channel_map_assignments.update.succeeded", - "settings.channel_map_assignments.update.succeeded", - "settings.channel_map_templates.create.succeeded", - "settings.channel_map_templates.create.succeeded", - "settings.channel_map_templates.update.succeeded", - "settings.recording_profiles.create.succeeded", - "settings.recording_profiles.update.succeeded", - "settings.upload_policies.create.succeeded", - "settings.upload_policies.update.succeeded", - "settings.watchdog_policies.create.succeeded", - "settings.watchdog_policies.update.succeeded", - ]); - - const profileAudit = audits.find( - (event) => event.action === "settings.recording_profiles.update.succeeded", - ); - - assert.equal(profileAudit?.before?.name, "Voice MP3 VBR"); - assert.equal(profileAudit?.after?.name, "Operations Voice MP3"); - const watchdogAudit = audits.find( - (event) => - event.action === "settings.watchdog_policies.update.succeeded" && - event.after?.name === "Operations Voice Watchdog", - ); - - assert.equal(watchdogAudit?.after?.channelCorrelationMode, "alert_on_high"); - assert.equal(watchdogAudit?.after?.broadbandNoiseScoreThreshold, 0.84); - assert.equal(watchdogAudit?.after?.channelCorrelationThreshold, 0.97); - assert.equal(watchdogAudit?.after?.clippingMode, "alert_on_clipping"); - assert.equal(watchdogAudit?.after?.flatlineMode, "alert_on_flatline"); - assert.equal(watchdogAudit?.after?.flatlineThresholdDbfs, -105); - assert.equal(watchdogAudit?.after?.humScoreThreshold, 0.76); - assert.equal(watchdogAudit?.after?.minCumulativeChannelCorrelationSeconds, 15); - assert.equal(watchdogAudit?.after?.minCumulativeClippingSeconds, 2); - assert.equal(watchdogAudit?.after?.minCumulativeFlatlineSeconds, 12); - assert.equal(watchdogAudit?.after?.minCumulativeQualitySeconds, 18); - assert.equal(watchdogAudit?.after?.noiseScoreThreshold, 0.88); - assert.equal(watchdogAudit?.after?.qualityAlertMode, "alert_on_noise_hum_static"); - assert.equal(watchdogAudit?.after?.staticScoreThreshold, 0.79); - const bulkAudit = audits.find( - (event) => event.action === "settings.channel_map_assignments.bulk_update.succeeded", - ); - - assert.equal(bulkAudit?.after?.targetCount, 2); - assert.equal(bulkAudit?.target.type, "channel_map_assignment_collection"); - const planApplyAudit = audits.find( - (event) => event.action === "settings.channel_map_assignment_plans.apply.succeeded", - ); - - assert.equal(planApplyAudit?.before?.status, "pending"); - assert.equal(planApplyAudit?.after?.plan.status, "applied"); - assert.equal(planApplyAudit?.after?.targetCount, 2); -}); - -function requestJson( - app: Hono, - path: string, - method: "PATCH" | "POST" | "PUT", - body: Record, -) { - return app.request(path, { - body: JSON.stringify(body), - headers: { "content-type": "application/json" }, - method, - }); -} - -async function jsonData(app: Hono, path: string) { - const response = await app.request(path); - const body = (await response.json()) as { data: Record }; - - assert.equal(response.status, 200); - - return body.data; -} - -function allowPermission(): RequirePermission { - return () => async (_c, next) => { - await next(); - }; -} - -function denyMissingPermission( - auditStore: ReturnType, - currentUser: CurrentUser, -): RequirePermission { - return (permission, action, target) => async (c) => { - const auditTarget = target ? await target(c) : { type: "controller" as const }; - - await recordAuditEvent(auditStore)(c, { - action, - auth: { user: currentUser }, - details: { - requiredPermission: permission, - resourceScope: auditTarget, - roles: currentUser.roles, - }, - outcome: "denied", - permission, - reason: "missing_permission", - target: auditTarget, - }); - - return c.json({ error: "Forbidden", permission }, 403); - }; -} - -function denyResourceScope( - auditStore: ReturnType, - currentUser: CurrentUser, - isVisibleTarget: (target: AuditTarget) => boolean, -): RequirePermission { - return (permission, action, target) => async (c, next) => { - const auditTarget = target ? await target(c) : { type: "controller" as const }; - const hasPermission = currentUser.permissions.includes(permission); - const allowed = hasPermission && isVisibleTarget(auditTarget); - - await recordAuditEvent(auditStore)(c, { - action, - auth: { user: currentUser }, - details: { - requiredPermission: permission, - resourceScope: auditTarget, - roles: currentUser.roles, - }, - outcome: allowed ? "allowed" : "denied", - permission, - reason: hasPermission ? "access_policy_denied" : "missing_permission", - target: auditTarget, - }); - - if (!allowed) { - return c.json({ error: "Forbidden", permission }, 403); - } - - await next(); - }; -} - -function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { - return async (_c, input) => { - const actor = input.actor ?? { - id: input.auth?.user?.id ?? "anonymous", - name: input.auth?.user?.name ?? "Anonymous", - roles: input.auth?.user?.roles ?? [], - type: "user" as const, - }; - const event: AuditEvent = { - action: input.action, - actor, - actorContext: {}, - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: input.details ?? {}, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; - }; -} - -function viewer(permissions = ["settings:read"]): CurrentUser { - return { - email: "settings-viewer@example.com", - groups: [], - id: "user_settings_viewer_test", - name: "Settings Viewer Test", - permissions, - provider: "local", - resourceGrants: [], - roles: ["viewer"], - }; -} - -function channelMapInput(id: string, name: string) { - return { - channelMode: "mono_to_stereo_mix", - entries: [ - { - included: true, - label: "Podium Mic", - outputChannelIndex: 1, - sourceChannelIndex: 1, - }, - ], - id, - name, - tags: ["voice"], - }; -} diff --git a/apps/api/test/upload-runner-harness.ts b/apps/api/test/upload-runner-harness.ts new file mode 100644 index 0000000..b1b7600 --- /dev/null +++ b/apps/api/test/upload-runner-harness.ts @@ -0,0 +1,191 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { AuditEvent, CurrentUser, RecordingSummary } from "@rakkr/shared"; +import type { RecordAuditEvent, RequirePermission } from "../src/http-types.js"; + +export { memoryRecordingStore } from "./recording-store-mock.js"; + +export const runnerRoot = await mkdtemp(path.join(tmpdir(), "rakkr-upload-runner-")); +process.env.RAKKR_UPLOAD_DESTINATION_STORE_PATH = path.join(runnerRoot, "destinations.json"); +process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(runnerRoot, "policies.json"); +process.env.RAKKR_UPLOAD_QUEUE_STORE_PATH = path.join(runnerRoot, "queue.json"); +process.env.RAKKR_RECORDING_CACHE_DIR = path.join(runnerRoot, "cache"); +process.env.RAKKR_RECORDING_CHUNK_STORE_PATH = path.join(runnerRoot, "chunks.json"); + +export const { createAuditStore } = await import("../src/audit-store.js"); +export const { createHealthEventStore } = await import("../src/health-store.js"); +export const { createUploadPolicy } = await import("../src/upload-policies.js"); +export const { createUploadDestinationStore } = await import("../src/upload-destinations.js"); +export const { registerUploadRunnerRoutes } = await import("../src/upload-runner-routes.js"); +export const { createUploadRunner } = await import("../src/upload-runner.js"); +export const { enqueueRecordingUpload, listUploadQueueItems } = + await import("../src/upload-queue.js"); +export const { upsertRecordingChunk } = await import("../src/recording-chunks.js"); + +test.after(async () => { + await rm(runnerRoot, { force: true, recursive: true }); +}); + +export const allow: RequirePermission = () => async (_c, next) => { + await next(); +}; + +export function denyMissingPermission( + auditStore: ReturnType, +): RequirePermission { + return (permission, action, target) => async (c) => { + const auditTarget = target ? await target(c) : { type: "controller" as const }; + + await recordAuditEvent(auditStore)(c, { + action, + auth: { user: viewer() }, + details: { + requiredPermission: permission, + resourceScope: auditTarget, + roles: ["viewer"], + }, + outcome: "denied", + permission, + reason: "missing_permission", + target: auditTarget, + }); + + return c.json({ error: "Forbidden", permission }, 403); + }; +} + +export function recordAuditEvent( + auditStore: ReturnType, +): RecordAuditEvent { + return async (_c, input) => { + const actor = input.actor ?? { + id: input.auth?.user?.id ?? "anonymous", + name: input.auth?.user?.name ?? "Anonymous", + roles: input.auth?.user?.roles ?? [], + type: "user" as const, + }; + const event: AuditEvent = { + action: input.action, + actor, + actorContext: {}, + after: input.after, + before: input.before, + correlationIds: input.correlationIds, + createdAt: new Date().toISOString(), + details: input.details ?? {}, + id: `audit_${randomUUID()}`, + outcome: input.outcome, + permission: input.permission, + reason: input.reason, + target: input.target, + }; + + await auditStore.append(event); + + return event; + }; +} + +export function user(): CurrentUser { + return { + email: "upload-runner@example.com", + groups: [], + id: "user_upload_runner_test", + name: "Upload Runner Test", + permissions: ["recording:control", "recording:read"], + provider: "local", + resourceGrants: [], + roles: ["admin"], + }; +} + +export function viewer(permissions: CurrentUser["permissions"] = []): CurrentUser { + return { + email: "upload-runner-viewer@example.com", + groups: [], + id: "user_upload_runner_viewer_test", + name: "Upload Runner Viewer Test", + permissions, + provider: "local", + resourceGrants: [], + roles: ["viewer"], + }; +} + +export function recording(id = "rec_upload_runner_test", contents?: string): RecordingSummary { + return { + cachePath: `scheduled/${id}.mp3`, + cached: true, + checksum: contents ? sha256Prefixed(contents) : `sha256:${id}`, + durationSeconds: 900, + folder: "Meetings/2026", + healthStatus: "healthy", + id, + name: "Council Meeting", + recordedAt: "2026-06-18T12:00:00.000Z", + source: "schedule", + status: "cached", + tags: ["council"], + }; +} + +export async function cacheRecording(id: string, contents: string) { + const cachePath = path.join(runnerRoot, "cache", "scheduled", `${id}.mp3`); + + await mkdir(path.dirname(cachePath), { recursive: true }); + await writeFile(cachePath, contents); + + return cachePath; +} + +export function sha256Prefixed(contents: string) { + return `sha256:${createHash("sha256").update(contents).digest("hex")}`; +} + +export function throwingSmbClient() { + return { + async close() {}, + async connect() { + throw new Error("smb_connect_failed"); + }, + async mkdir() {}, + async readFile() { + return Buffer.alloc(0); + }, + async writeFile() {}, + }; +} + +export function fakeSmbClient() { + const files = new Map(); + const dirs: string[] = []; + + return { + client: { + async close() {}, + async connect() {}, + async mkdir(targetPath: string) { + dirs.push(targetPath); + }, + async readFile(targetPath: string) { + const data = files.get(targetPath); + + if (!data) { + const error = new Error("ENOENT") as Error & { code: string }; + error.code = "ENOENT"; + throw error; + } + + return data; + }, + async writeFile(targetPath: string, data: Buffer | string) { + files.set(targetPath, Buffer.from(data)); + }, + }, + dirs, + files, + }; +} diff --git a/apps/api/test/upload-runner-reconcile.test.ts b/apps/api/test/upload-runner-reconcile.test.ts new file mode 100644 index 0000000..d45a7fd --- /dev/null +++ b/apps/api/test/upload-runner-reconcile.test.ts @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { + cacheRecording, + createAuditStore, + createUploadDestinationStore, + createUploadPolicy, + createUploadRunner, + enqueueRecordingUpload, + fakeSmbClient, + memoryRecordingStore, + recording, + runnerRoot, + upsertRecordingChunk, +} from "./upload-runner-harness.js"; + +test("G54: reconcile does not overwrite a recording a retry reset to `recording`", async () => { + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const contents = "retry-race-bytes"; + // Mid-retry: the recording was reset to `recording`, but a prior attempt left + // a settled upload-queue item the reconcile would otherwise promote. + const retried = { + ...recording("rec_upload_retry_race", contents), + status: "recording" as const, + }; + const cachePath = await cacheRecording(retried.id, contents); + const recordingStore = memoryRecordingStore([retried]); + const smb = fakeSmbClient(); + const runner = createUploadRunner({ + auditStore, + destinationStore, + limit: 5, + recordingStore, + smbClientFactory: () => smb.client, + }); + + const destination = await destinationStore.create({ + displayName: "Race Share", + enabled: true, + kind: "smb", + smb: { server: "files.example.lan", share: "recordings", username: "svc" }, + smbPassword: "s3cr3t", + }); + const policy = await createUploadPolicy({ + deleteCacheAfterUpload: true, + destinationId: destination.id, + enabled: true, + maxAttempts: 1, + name: "Race then delete cache", + trigger: "manual", + }); + await enqueueRecordingUpload(retried, { + destinationId: destination.id, + maxAttempts: 1, + policyId: policy.id, + provider: "smb", + }); + + await runner.runOnce(); + const updated = await recordingStore.find(retried.id); + + // Pre-fix the reconcile blind-saved "uploaded" over the retry's fresh state + // and deleted its cache; now it re-reads and skips. + assert.equal(updated?.status, "recording"); + await assert.doesNotReject(readFile(cachePath)); +}); + +test("G55: chunked reconcile does not promote a recording reset to `recording`", async () => { + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const contents = "chunk-race-bytes"; + const retried = { + ...recording("rec_upload_chunk_race", contents), + status: "recording" as const, + }; + const recordingStore = memoryRecordingStore([retried]); + const chunkId = "rec_upload_chunk_race:1"; + const chunkCacheRel = `chunks/${chunkId}.mp3`; + const chunkCachePath = path.join(runnerRoot, "cache", chunkCacheRel); + + await mkdir(path.dirname(chunkCachePath), { recursive: true }); + await writeFile(chunkCachePath, contents); + await upsertRecordingChunk({ + cachePath: chunkCacheRel, + createdAt: "2026-06-18T12:00:00.000Z", + durationSeconds: 60, + id: chunkId, + index: 1, + jobId: "job_chunk_race", + offsetSeconds: 0, + recordingId: retried.id, + status: "cached", + total: 1, + }); + + const smb = fakeSmbClient(); + const runner = createUploadRunner({ + auditStore, + destinationStore, + limit: 5, + recordingStore, + smbClientFactory: () => smb.client, + }); + + const destination = await destinationStore.create({ + displayName: "Chunk Race Share", + enabled: true, + kind: "smb", + smb: { server: "files.example.lan", share: "recordings", username: "svc" }, + smbPassword: "s3cr3t", + }); + const policy = await createUploadPolicy({ + deleteCacheAfterUpload: false, + destinationId: destination.id, + enabled: true, + maxAttempts: 1, + name: "Chunk race policy", + trigger: "manual", + }); + await enqueueRecordingUpload(retried, { + cachePath: chunkCacheRel, + chunkId, + chunkIndex: 1, + destinationId: destination.id, + fileName: "chunk-1.mp3", + maxAttempts: 1, + policyId: policy.id, + provider: "smb", + }); + + await runner.runOnce(); + const updated = await recordingStore.find(retried.id); + + // Pre-fix the chunked reconcile blind-saved "uploaded" over the retry state. + assert.equal(updated?.status, "recording"); +}); + +test("G55-d: a stale chunked reconcile does not delete a re-captured chunk's cache", async () => { + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const contents = "chunk-recapture-bytes"; + const retried = { + ...recording("rec_upload_chunk_recapture", contents), + status: "recording" as const, + }; + const recordingStore = memoryRecordingStore([retried]); + const chunkId = "rec_upload_chunk_recapture:1"; + const chunkCacheRel = `chunks/${chunkId}.mp3`; + const chunkCachePath = path.join(runnerRoot, "cache", chunkCacheRel); + + await mkdir(path.dirname(chunkCachePath), { recursive: true }); + await writeFile(chunkCachePath, contents); + await upsertRecordingChunk({ + cachePath: chunkCacheRel, + createdAt: "2026-06-18T12:00:00.000Z", + durationSeconds: 60, + id: chunkId, + index: 1, + jobId: "job_chunk_recapture", + offsetSeconds: 0, + recordingId: retried.id, + status: "cached", + total: 1, + }); + + const smb = fakeSmbClient(); + const runner = createUploadRunner({ + auditStore, + destinationStore, + limit: 5, + recordingStore, + smbClientFactory: () => smb.client, + }); + const destination = await destinationStore.create({ + displayName: "Recapture Share", + enabled: true, + kind: "smb", + smb: { server: "files.example.lan", share: "recordings", username: "svc" }, + smbPassword: "s3cr3t", + }); + const policy = await createUploadPolicy({ + deleteCacheAfterUpload: true, + destinationId: destination.id, + enabled: true, + maxAttempts: 1, + name: "Recapture delete policy", + trigger: "manual", + }); + await enqueueRecordingUpload(retried, { + cachePath: chunkCacheRel, + chunkId, + chunkIndex: 1, + destinationId: destination.id, + fileName: "chunk-1.mp3", + maxAttempts: 1, + policyId: policy.id, + provider: "smb", + }); + + await runner.runOnce(); + + // Pre-fix the per-chunk deletion ran before the recording-status guard, so a + // stale pass (recording reset to `recording` by a retry) deleted the + // re-captured chunk's cache. The hoisted guard now skips the whole pass. + await assert.doesNotReject(readFile(chunkCachePath)); +}); diff --git a/apps/api/test/upload-runner-routes.test.ts b/apps/api/test/upload-runner-routes.test.ts new file mode 100644 index 0000000..b2dba41 --- /dev/null +++ b/apps/api/test/upload-runner-routes.test.ts @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { Hono } from "hono"; +import type { AppBindings } from "../src/http-types.js"; +import { + allow, + createAuditStore, + createUploadDestinationStore, + createUploadRunner, + denyMissingPermission, + enqueueRecordingUpload, + listUploadQueueItems, + recordAuditEvent, + recording, + registerUploadRunnerRoutes, + user, + viewer, +} from "./upload-runner-harness.js"; + +test("upload runner routes expose status and run-now control", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); + + registerUploadRunnerRoutes({ + app, + currentAuth: () => ({ user: user() }), + recordAuditEvent: recordAuditEvent(auditStore), + requirePermission: allow, + uploadRunner: runner, + }); + await enqueueRecordingUpload(recording("rec_upload_runner_route_test"), { + provider: "stub", + target: "stub://queue-only", + }); + + const before = await app.request("/api/v1/upload-runner"); + const actions = await app.request("/api/v1/upload-runner/actions"); + const run = await app.request("/api/v1/upload-runner/run", { method: "POST" }); + const actionPayload = (await actions.json()) as { + data: { actions: { run: { enabled: boolean; href?: string } } }; + }; + const payload = await run.json(); + const readEvents = await auditStore.list({ + action: "recordings.upload_runner.read.succeeded", + }); + const actionEvents = await auditStore.list({ + action: "recordings.upload_runner.actions.read.succeeded", + }); + const events = await auditStore.list({ action: "recordings.upload_runner.run.succeeded" }); + + assert.equal(before.status, 200); + assert.equal(actions.status, 200); + assert.equal(actionPayload.data.actions.run.enabled, true); + assert.equal(actionPayload.data.actions.run.href, "/api/v1/upload-runner/run"); + assert.equal(run.status, 200); + assert.equal(payload.summary.succeeded, 1); + assert.equal(payload.data.lastSummary.succeeded, 1); + assert.equal(readEvents.length, 1); + assert.equal(readEvents[0]?.permission, "recording:read"); + assert.equal(readEvents[0]?.target.type, "upload_runner"); + assert.equal(readEvents[0]?.details.started, false); + assert.equal(readEvents[0]?.details.lastSummaryAttempted, 0); + assert.equal(actionEvents.length, 1); + assert.equal(actionEvents[0]?.permission, "recording:read"); + assert.equal(actionEvents[0]?.target.type, "upload_runner"); + assert.equal(actionEvents[0]?.details.started, false); + assert.equal(actionEvents[0]?.details.visibleActionCount, 2); + assert.equal(events.length, 1); + assert.equal(events[0]?.actor.id, "user_upload_runner_test"); +}); + +test("upload runner run route only processes queue items for scoped recordings", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); + const visible = recording(`rec_upload_visible_${randomUUID()}`); + const hidden = recording(`rec_upload_hidden_${randomUUID()}`); + const visibleItem = await enqueueRecordingUpload(visible, { + provider: "stub", + target: "stub://visible", + }); + const hiddenItem = await enqueueRecordingUpload(hidden, { + provider: "stub", + target: "stub://hidden", + }); + + registerUploadRunnerRoutes({ + app, + currentAuth: () => ({ user: user() }), + recordAuditEvent: recordAuditEvent(auditStore), + requirePermission: allow, + scopedRecordings: async () => [visible], + uploadRunner: runner, + }); + + const response = await app.request("/api/v1/upload-runner/run", { method: "POST" }); + const body = (await response.json()) as { + summary: { attempted: number; items: Array<{ recordingId: string }>; succeeded: number }; + }; + const items = await listUploadQueueItems(); + const storedVisible = items.find((item) => item.id === visibleItem.id); + const storedHidden = items.find((item) => item.id === hiddenItem.id); + const itemEvents = await auditStore.list({ + action: "recordings.upload_queue.runner_item.succeeded", + }); + + assert.equal(response.status, 200); + assert.equal(body.summary.attempted, 1); + assert.equal(body.summary.succeeded, 1); + assert.deepEqual( + body.summary.items.map((item) => item.recordingId), + [visible.id], + ); + assert.equal(storedVisible?.status, "succeeded"); + assert.equal(storedHidden?.status, "queued"); + assert.equal(storedHidden?.attemptCount, 0); + assert.deepEqual( + itemEvents.map((event) => event.target.id), + [visible.id], + ); +}); + +test("upload runner status routes hide last-summary items outside scoped recordings", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); + const visible = recording(`rec_upload_status_visible_${randomUUID()}`); + const hidden = recording(`rec_upload_status_hidden_${randomUUID()}`); + + await enqueueRecordingUpload(visible, { + provider: "stub", + target: "stub://visible", + }); + await enqueueRecordingUpload(hidden, { + provider: "stub", + target: "stub://hidden", + }); + await runner.runOnce(); + + registerUploadRunnerRoutes({ + app, + currentAuth: () => ({ user: user() }), + recordAuditEvent: recordAuditEvent(auditStore), + requirePermission: allow, + scopedRecordings: async () => [visible], + uploadRunner: runner, + }); + + const statusResponse = await app.request("/api/v1/upload-runner"); + const actionsResponse = await app.request("/api/v1/upload-runner/actions"); + const statusBody = (await statusResponse.json()) as { + data: { lastSummary?: { attempted: number; items: Array<{ recordingId: string }> } }; + }; + const actionsBody = (await actionsResponse.json()) as { + data: { + status: { lastSummary?: { attempted: number; items: Array<{ recordingId: string }> } }; + }; + }; + const [readEvent] = await auditStore.list({ + action: "recordings.upload_runner.read.succeeded", + }); + const [actionEvent] = await auditStore.list({ + action: "recordings.upload_runner.actions.read.succeeded", + }); + + assert.equal(statusResponse.status, 200); + assert.equal(actionsResponse.status, 200); + assert.ok((runner.status().lastSummary?.attempted ?? 0) > 1); + assert.equal(statusBody.data.lastSummary?.attempted, 1); + assert.deepEqual( + statusBody.data.lastSummary?.items.map((item) => item.recordingId), + [visible.id], + ); + assert.deepEqual(actionsBody.data.status.lastSummary, statusBody.data.lastSummary); + assert.equal(readEvent?.details.lastSummaryAttempted, 1); + assert.equal(readEvent?.details.lastSummaryItemCount, 1); + assert.equal(actionEvent?.details.lastSummaryAttempted, 1); + assert.equal(actionEvent?.details.lastSummaryItemCount, 1); +}); + +test("upload runner routes deny users without required permissions", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); + + registerUploadRunnerRoutes({ + app, + currentAuth: () => ({ user: viewer() }), + recordAuditEvent: recordAuditEvent(auditStore), + requirePermission: denyMissingPermission(auditStore), + uploadRunner: runner, + }); + await enqueueRecordingUpload(recording("rec_upload_runner_denied_route_test"), { + provider: "stub", + target: "stub://queue-only", + }); + + const readResponse = await app.request("/api/v1/upload-runner"); + const actionsResponse = await app.request("/api/v1/upload-runner/actions"); + const runResponse = await app.request("/api/v1/upload-runner/run", { method: "POST" }); + const status = runner.status(); + const events = await auditStore.list({ outcome: "denied" }); + + assert.deepEqual( + [readResponse.status, actionsResponse.status, runResponse.status], + [403, 403, 403], + ); + assert.equal(status.lastSummary, undefined); + assert.deepEqual( + Object.fromEntries(events.map((event) => [event.action, event.permission]).sort()), + { + "recordings.upload_runner.actions.read": "recording:read", + "recordings.upload_runner.read": "recording:read", + "recordings.upload_runner.run": "recording:control", + }, + ); + assert.ok(events.every((event) => event.reason === "missing_permission")); + assert.ok(events.every((event) => event.target.type === "upload_runner")); +}); + +test("upload runner action summary reports missing control permission", async () => { + const app = new Hono(); + const auditStore = createAuditStore(""); + const destinationStore = createUploadDestinationStore(); + const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); + + registerUploadRunnerRoutes({ + app, + currentAuth: () => ({ user: viewer(["recording:read"]) }), + recordAuditEvent: recordAuditEvent(auditStore), + requirePermission: allow, + uploadRunner: runner, + }); + + const response = await app.request("/api/v1/upload-runner/actions"); + const body = (await response.json()) as { + data: { actions: { run: { enabled: boolean; reason?: string } } }; + }; + const [event] = await auditStore.list({ + action: "recordings.upload_runner.actions.read.succeeded", + }); + + assert.equal(response.status, 200); + assert.equal(body.data.actions.run.enabled, false); + assert.equal(body.data.actions.run.reason, "missing_permission"); + assert.equal(event?.outcome, "succeeded"); + assert.equal(event?.details.visibleActionCount, 2); +}); diff --git a/apps/api/test/upload-runner.test.ts b/apps/api/test/upload-runner.test.ts index d08be54..7718faf 100644 --- a/apps/api/test/upload-runner.test.ts +++ b/apps/api/test/upload-runner.test.ts @@ -1,33 +1,23 @@ import assert from "node:assert/strict"; -import { createHash, randomUUID } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; -import { Hono } from "hono"; -import type { AuditEvent, CurrentUser, RecordingSummary } from "@rakkr/shared"; -import type { AppBindings, RecordAuditEvent, RequirePermission } from "../src/http-types.js"; -import { memoryRecordingStore } from "./recording-store-mock.js"; - -const runnerRoot = await mkdtemp(path.join(tmpdir(), "rakkr-upload-runner-")); -process.env.RAKKR_UPLOAD_DESTINATION_STORE_PATH = path.join(runnerRoot, "destinations.json"); -process.env.RAKKR_UPLOAD_POLICY_STORE_PATH = path.join(runnerRoot, "policies.json"); -process.env.RAKKR_UPLOAD_QUEUE_STORE_PATH = path.join(runnerRoot, "queue.json"); -process.env.RAKKR_RECORDING_CACHE_DIR = path.join(runnerRoot, "cache"); -process.env.RAKKR_RECORDING_CHUNK_STORE_PATH = path.join(runnerRoot, "chunks.json"); - -const { createAuditStore } = await import("../src/audit-store.js"); -const { createHealthEventStore } = await import("../src/health-store.js"); -const { createUploadPolicy } = await import("../src/upload-policies.js"); -const { createUploadDestinationStore } = await import("../src/upload-destinations.js"); -const { registerUploadRunnerRoutes } = await import("../src/upload-runner-routes.js"); -const { createUploadRunner } = await import("../src/upload-runner.js"); -const { enqueueRecordingUpload, listUploadQueueItems } = await import("../src/upload-queue.js"); -const { upsertRecordingChunk } = await import("../src/recording-chunks.js"); - -test.after(async () => { - await rm(runnerRoot, { force: true, recursive: true }); -}); +import { + cacheRecording, + createAuditStore, + createHealthEventStore, + createUploadDestinationStore, + createUploadPolicy, + createUploadRunner, + enqueueRecordingUpload, + fakeSmbClient, + memoryRecordingStore, + recording, + runnerRoot, + sha256Prefixed, + throwingSmbClient, + upsertRecordingChunk, +} from "./upload-runner-harness.js"; test("upload runner processes queue items and records service audit events", async () => { const auditStore = createAuditStore(""); @@ -382,587 +372,3 @@ test("upload runner keeps a chunk's cache when one destination fails (chunked re // deletes cache — the chunk analogue of the whole-recording partial gate. await assert.doesNotReject(readFile(chunkCachePath)); }); - -test("G54: reconcile does not overwrite a recording a retry reset to `recording`", async () => { - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const contents = "retry-race-bytes"; - // Mid-retry: the recording was reset to `recording`, but a prior attempt left - // a settled upload-queue item the reconcile would otherwise promote. - const retried = { - ...recording("rec_upload_retry_race", contents), - status: "recording" as const, - }; - const cachePath = await cacheRecording(retried.id, contents); - const recordingStore = memoryRecordingStore([retried]); - const smb = fakeSmbClient(); - const runner = createUploadRunner({ - auditStore, - destinationStore, - limit: 5, - recordingStore, - smbClientFactory: () => smb.client, - }); - - const destination = await destinationStore.create({ - displayName: "Race Share", - enabled: true, - kind: "smb", - smb: { server: "files.example.lan", share: "recordings", username: "svc" }, - smbPassword: "s3cr3t", - }); - const policy = await createUploadPolicy({ - deleteCacheAfterUpload: true, - destinationId: destination.id, - enabled: true, - maxAttempts: 1, - name: "Race then delete cache", - trigger: "manual", - }); - await enqueueRecordingUpload(retried, { - destinationId: destination.id, - maxAttempts: 1, - policyId: policy.id, - provider: "smb", - }); - - await runner.runOnce(); - const updated = await recordingStore.find(retried.id); - - // Pre-fix the reconcile blind-saved "uploaded" over the retry's fresh state - // and deleted its cache; now it re-reads and skips. - assert.equal(updated?.status, "recording"); - await assert.doesNotReject(readFile(cachePath)); -}); - -test("G55: chunked reconcile does not promote a recording reset to `recording`", async () => { - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const contents = "chunk-race-bytes"; - const retried = { - ...recording("rec_upload_chunk_race", contents), - status: "recording" as const, - }; - const recordingStore = memoryRecordingStore([retried]); - const chunkId = "rec_upload_chunk_race:1"; - const chunkCacheRel = `chunks/${chunkId}.mp3`; - const chunkCachePath = path.join(runnerRoot, "cache", chunkCacheRel); - - await mkdir(path.dirname(chunkCachePath), { recursive: true }); - await writeFile(chunkCachePath, contents); - await upsertRecordingChunk({ - cachePath: chunkCacheRel, - createdAt: "2026-06-18T12:00:00.000Z", - durationSeconds: 60, - id: chunkId, - index: 1, - jobId: "job_chunk_race", - offsetSeconds: 0, - recordingId: retried.id, - status: "cached", - total: 1, - }); - - const smb = fakeSmbClient(); - const runner = createUploadRunner({ - auditStore, - destinationStore, - limit: 5, - recordingStore, - smbClientFactory: () => smb.client, - }); - - const destination = await destinationStore.create({ - displayName: "Chunk Race Share", - enabled: true, - kind: "smb", - smb: { server: "files.example.lan", share: "recordings", username: "svc" }, - smbPassword: "s3cr3t", - }); - const policy = await createUploadPolicy({ - deleteCacheAfterUpload: false, - destinationId: destination.id, - enabled: true, - maxAttempts: 1, - name: "Chunk race policy", - trigger: "manual", - }); - await enqueueRecordingUpload(retried, { - cachePath: chunkCacheRel, - chunkId, - chunkIndex: 1, - destinationId: destination.id, - fileName: "chunk-1.mp3", - maxAttempts: 1, - policyId: policy.id, - provider: "smb", - }); - - await runner.runOnce(); - const updated = await recordingStore.find(retried.id); - - // Pre-fix the chunked reconcile blind-saved "uploaded" over the retry state. - assert.equal(updated?.status, "recording"); -}); - -test("G55-d: a stale chunked reconcile does not delete a re-captured chunk's cache", async () => { - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const contents = "chunk-recapture-bytes"; - const retried = { - ...recording("rec_upload_chunk_recapture", contents), - status: "recording" as const, - }; - const recordingStore = memoryRecordingStore([retried]); - const chunkId = "rec_upload_chunk_recapture:1"; - const chunkCacheRel = `chunks/${chunkId}.mp3`; - const chunkCachePath = path.join(runnerRoot, "cache", chunkCacheRel); - - await mkdir(path.dirname(chunkCachePath), { recursive: true }); - await writeFile(chunkCachePath, contents); - await upsertRecordingChunk({ - cachePath: chunkCacheRel, - createdAt: "2026-06-18T12:00:00.000Z", - durationSeconds: 60, - id: chunkId, - index: 1, - jobId: "job_chunk_recapture", - offsetSeconds: 0, - recordingId: retried.id, - status: "cached", - total: 1, - }); - - const smb = fakeSmbClient(); - const runner = createUploadRunner({ - auditStore, - destinationStore, - limit: 5, - recordingStore, - smbClientFactory: () => smb.client, - }); - const destination = await destinationStore.create({ - displayName: "Recapture Share", - enabled: true, - kind: "smb", - smb: { server: "files.example.lan", share: "recordings", username: "svc" }, - smbPassword: "s3cr3t", - }); - const policy = await createUploadPolicy({ - deleteCacheAfterUpload: true, - destinationId: destination.id, - enabled: true, - maxAttempts: 1, - name: "Recapture delete policy", - trigger: "manual", - }); - await enqueueRecordingUpload(retried, { - cachePath: chunkCacheRel, - chunkId, - chunkIndex: 1, - destinationId: destination.id, - fileName: "chunk-1.mp3", - maxAttempts: 1, - policyId: policy.id, - provider: "smb", - }); - - await runner.runOnce(); - - // Pre-fix the per-chunk deletion ran before the recording-status guard, so a - // stale pass (recording reset to `recording` by a retry) deleted the - // re-captured chunk's cache. The hoisted guard now skips the whole pass. - await assert.doesNotReject(readFile(chunkCachePath)); -}); - -test("upload runner routes expose status and run-now control", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); - - registerUploadRunnerRoutes({ - app, - currentAuth: () => ({ user: user() }), - recordAuditEvent: recordAuditEvent(auditStore), - requirePermission: allow, - uploadRunner: runner, - }); - await enqueueRecordingUpload(recording("rec_upload_runner_route_test"), { - provider: "stub", - target: "stub://queue-only", - }); - - const before = await app.request("/api/v1/upload-runner"); - const actions = await app.request("/api/v1/upload-runner/actions"); - const run = await app.request("/api/v1/upload-runner/run", { method: "POST" }); - const actionPayload = (await actions.json()) as { - data: { actions: { run: { enabled: boolean; href?: string } } }; - }; - const payload = await run.json(); - const readEvents = await auditStore.list({ - action: "recordings.upload_runner.read.succeeded", - }); - const actionEvents = await auditStore.list({ - action: "recordings.upload_runner.actions.read.succeeded", - }); - const events = await auditStore.list({ action: "recordings.upload_runner.run.succeeded" }); - - assert.equal(before.status, 200); - assert.equal(actions.status, 200); - assert.equal(actionPayload.data.actions.run.enabled, true); - assert.equal(actionPayload.data.actions.run.href, "/api/v1/upload-runner/run"); - assert.equal(run.status, 200); - assert.equal(payload.summary.succeeded, 1); - assert.equal(payload.data.lastSummary.succeeded, 1); - assert.equal(readEvents.length, 1); - assert.equal(readEvents[0]?.permission, "recording:read"); - assert.equal(readEvents[0]?.target.type, "upload_runner"); - assert.equal(readEvents[0]?.details.started, false); - assert.equal(readEvents[0]?.details.lastSummaryAttempted, 0); - assert.equal(actionEvents.length, 1); - assert.equal(actionEvents[0]?.permission, "recording:read"); - assert.equal(actionEvents[0]?.target.type, "upload_runner"); - assert.equal(actionEvents[0]?.details.started, false); - assert.equal(actionEvents[0]?.details.visibleActionCount, 2); - assert.equal(events.length, 1); - assert.equal(events[0]?.actor.id, "user_upload_runner_test"); -}); - -test("upload runner run route only processes queue items for scoped recordings", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); - const visible = recording(`rec_upload_visible_${randomUUID()}`); - const hidden = recording(`rec_upload_hidden_${randomUUID()}`); - const visibleItem = await enqueueRecordingUpload(visible, { - provider: "stub", - target: "stub://visible", - }); - const hiddenItem = await enqueueRecordingUpload(hidden, { - provider: "stub", - target: "stub://hidden", - }); - - registerUploadRunnerRoutes({ - app, - currentAuth: () => ({ user: user() }), - recordAuditEvent: recordAuditEvent(auditStore), - requirePermission: allow, - scopedRecordings: async () => [visible], - uploadRunner: runner, - }); - - const response = await app.request("/api/v1/upload-runner/run", { method: "POST" }); - const body = (await response.json()) as { - summary: { attempted: number; items: Array<{ recordingId: string }>; succeeded: number }; - }; - const items = await listUploadQueueItems(); - const storedVisible = items.find((item) => item.id === visibleItem.id); - const storedHidden = items.find((item) => item.id === hiddenItem.id); - const itemEvents = await auditStore.list({ - action: "recordings.upload_queue.runner_item.succeeded", - }); - - assert.equal(response.status, 200); - assert.equal(body.summary.attempted, 1); - assert.equal(body.summary.succeeded, 1); - assert.deepEqual( - body.summary.items.map((item) => item.recordingId), - [visible.id], - ); - assert.equal(storedVisible?.status, "succeeded"); - assert.equal(storedHidden?.status, "queued"); - assert.equal(storedHidden?.attemptCount, 0); - assert.deepEqual( - itemEvents.map((event) => event.target.id), - [visible.id], - ); -}); - -test("upload runner status routes hide last-summary items outside scoped recordings", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); - const visible = recording(`rec_upload_status_visible_${randomUUID()}`); - const hidden = recording(`rec_upload_status_hidden_${randomUUID()}`); - - await enqueueRecordingUpload(visible, { - provider: "stub", - target: "stub://visible", - }); - await enqueueRecordingUpload(hidden, { - provider: "stub", - target: "stub://hidden", - }); - await runner.runOnce(); - - registerUploadRunnerRoutes({ - app, - currentAuth: () => ({ user: user() }), - recordAuditEvent: recordAuditEvent(auditStore), - requirePermission: allow, - scopedRecordings: async () => [visible], - uploadRunner: runner, - }); - - const statusResponse = await app.request("/api/v1/upload-runner"); - const actionsResponse = await app.request("/api/v1/upload-runner/actions"); - const statusBody = (await statusResponse.json()) as { - data: { lastSummary?: { attempted: number; items: Array<{ recordingId: string }> } }; - }; - const actionsBody = (await actionsResponse.json()) as { - data: { - status: { lastSummary?: { attempted: number; items: Array<{ recordingId: string }> } }; - }; - }; - const [readEvent] = await auditStore.list({ - action: "recordings.upload_runner.read.succeeded", - }); - const [actionEvent] = await auditStore.list({ - action: "recordings.upload_runner.actions.read.succeeded", - }); - - assert.equal(statusResponse.status, 200); - assert.equal(actionsResponse.status, 200); - assert.ok((runner.status().lastSummary?.attempted ?? 0) > 1); - assert.equal(statusBody.data.lastSummary?.attempted, 1); - assert.deepEqual( - statusBody.data.lastSummary?.items.map((item) => item.recordingId), - [visible.id], - ); - assert.deepEqual(actionsBody.data.status.lastSummary, statusBody.data.lastSummary); - assert.equal(readEvent?.details.lastSummaryAttempted, 1); - assert.equal(readEvent?.details.lastSummaryItemCount, 1); - assert.equal(actionEvent?.details.lastSummaryAttempted, 1); - assert.equal(actionEvent?.details.lastSummaryItemCount, 1); -}); - -test("upload runner routes deny users without required permissions", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); - - registerUploadRunnerRoutes({ - app, - currentAuth: () => ({ user: viewer() }), - recordAuditEvent: recordAuditEvent(auditStore), - requirePermission: denyMissingPermission(auditStore), - uploadRunner: runner, - }); - await enqueueRecordingUpload(recording("rec_upload_runner_denied_route_test"), { - provider: "stub", - target: "stub://queue-only", - }); - - const readResponse = await app.request("/api/v1/upload-runner"); - const actionsResponse = await app.request("/api/v1/upload-runner/actions"); - const runResponse = await app.request("/api/v1/upload-runner/run", { method: "POST" }); - const status = runner.status(); - const events = await auditStore.list({ outcome: "denied" }); - - assert.deepEqual( - [readResponse.status, actionsResponse.status, runResponse.status], - [403, 403, 403], - ); - assert.equal(status.lastSummary, undefined); - assert.deepEqual( - Object.fromEntries(events.map((event) => [event.action, event.permission]).sort()), - { - "recordings.upload_runner.actions.read": "recording:read", - "recordings.upload_runner.read": "recording:read", - "recordings.upload_runner.run": "recording:control", - }, - ); - assert.ok(events.every((event) => event.reason === "missing_permission")); - assert.ok(events.every((event) => event.target.type === "upload_runner")); -}); - -test("upload runner action summary reports missing control permission", async () => { - const app = new Hono(); - const auditStore = createAuditStore(""); - const destinationStore = createUploadDestinationStore(); - const runner = createUploadRunner({ auditStore, limit: 5, destinationStore }); - - registerUploadRunnerRoutes({ - app, - currentAuth: () => ({ user: viewer(["recording:read"]) }), - recordAuditEvent: recordAuditEvent(auditStore), - requirePermission: allow, - uploadRunner: runner, - }); - - const response = await app.request("/api/v1/upload-runner/actions"); - const body = (await response.json()) as { - data: { actions: { run: { enabled: boolean; reason?: string } } }; - }; - const [event] = await auditStore.list({ - action: "recordings.upload_runner.actions.read.succeeded", - }); - - assert.equal(response.status, 200); - assert.equal(body.data.actions.run.enabled, false); - assert.equal(body.data.actions.run.reason, "missing_permission"); - assert.equal(event?.outcome, "succeeded"); - assert.equal(event?.details.visibleActionCount, 2); -}); - -const allow: RequirePermission = () => async (_c, next) => { - await next(); -}; - -function denyMissingPermission(auditStore: ReturnType): RequirePermission { - return (permission, action, target) => async (c) => { - const auditTarget = target ? await target(c) : { type: "controller" as const }; - - await recordAuditEvent(auditStore)(c, { - action, - auth: { user: viewer() }, - details: { - requiredPermission: permission, - resourceScope: auditTarget, - roles: ["viewer"], - }, - outcome: "denied", - permission, - reason: "missing_permission", - target: auditTarget, - }); - - return c.json({ error: "Forbidden", permission }, 403); - }; -} - -function recordAuditEvent(auditStore: ReturnType): RecordAuditEvent { - return async (_c, input) => { - const actor = input.actor ?? { - id: input.auth?.user?.id ?? "anonymous", - name: input.auth?.user?.name ?? "Anonymous", - roles: input.auth?.user?.roles ?? [], - type: "user" as const, - }; - const event: AuditEvent = { - action: input.action, - actor, - actorContext: {}, - after: input.after, - before: input.before, - correlationIds: input.correlationIds, - createdAt: new Date().toISOString(), - details: input.details ?? {}, - id: `audit_${randomUUID()}`, - outcome: input.outcome, - permission: input.permission, - reason: input.reason, - target: input.target, - }; - - await auditStore.append(event); - - return event; - }; -} - -function user(): CurrentUser { - return { - email: "upload-runner@example.com", - groups: [], - id: "user_upload_runner_test", - name: "Upload Runner Test", - permissions: ["recording:control", "recording:read"], - provider: "local", - resourceGrants: [], - roles: ["admin"], - }; -} - -function viewer(permissions: CurrentUser["permissions"] = []): CurrentUser { - return { - email: "upload-runner-viewer@example.com", - groups: [], - id: "user_upload_runner_viewer_test", - name: "Upload Runner Viewer Test", - permissions, - provider: "local", - resourceGrants: [], - roles: ["viewer"], - }; -} - -function recording(id = "rec_upload_runner_test", contents?: string): RecordingSummary { - return { - cachePath: `scheduled/${id}.mp3`, - cached: true, - checksum: contents ? sha256Prefixed(contents) : `sha256:${id}`, - durationSeconds: 900, - folder: "Meetings/2026", - healthStatus: "healthy", - id, - name: "Council Meeting", - recordedAt: "2026-06-18T12:00:00.000Z", - source: "schedule", - status: "cached", - tags: ["council"], - }; -} - -async function cacheRecording(id: string, contents: string) { - const cachePath = path.join(runnerRoot, "cache", "scheduled", `${id}.mp3`); - - await mkdir(path.dirname(cachePath), { recursive: true }); - await writeFile(cachePath, contents); - - return cachePath; -} - -function sha256Prefixed(contents: string) { - return `sha256:${createHash("sha256").update(contents).digest("hex")}`; -} - -function throwingSmbClient() { - return { - async close() {}, - async connect() { - throw new Error("smb_connect_failed"); - }, - async mkdir() {}, - async readFile() { - return Buffer.alloc(0); - }, - async writeFile() {}, - }; -} - -function fakeSmbClient() { - const files = new Map(); - const dirs: string[] = []; - - return { - client: { - async close() {}, - async connect() {}, - async mkdir(targetPath: string) { - dirs.push(targetPath); - }, - async readFile(targetPath: string) { - const data = files.get(targetPath); - - if (!data) { - const error = new Error("ENOENT") as Error & { code: string }; - error.code = "ENOENT"; - throw error; - } - - return data; - }, - async writeFile(targetPath: string, data: Buffer | string) { - files.set(targetPath, Buffer.from(data)); - }, - }, - dirs, - files, - }; -} diff --git a/crates/recorder-agent/src/inventory.rs b/crates/recorder-agent/src/inventory.rs index a7e7c27..1c9491b 100644 --- a/crates/recorder-agent/src/inventory.rs +++ b/crates/recorder-agent/src/inventory.rs @@ -1,12 +1,12 @@ -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::{env, fs}; - use serde::Serialize; use crate::config::AgentConfig; use crate::telemetry::now_rfc3339; +mod alsa; +mod net; +mod runtime; + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct NodeInventory { @@ -74,8 +74,8 @@ pub fn collect(config: &AgentConfig) -> NodeInventory { .and_then(|value| value.into_string().ok()) .unwrap_or_else(|| "unknown-host".to_string()); - let interfaces = discover_audio_interfaces(config); - let runtime = runtime_details(&interfaces); + let interfaces = alsa::discover_audio_interfaces(config); + let runtime = runtime::runtime_details(&interfaces); NodeInventory { agent_version: crate::version::AGENT_VERSION.to_string(), @@ -83,7 +83,7 @@ pub fn collect(config: &AgentConfig) -> NodeInventory { hostname, id: config.node_id.clone(), interfaces, - ip_addresses: collect_ip_addresses(), + ip_addresses: net::collect_ip_addresses(), last_seen_at: now_rfc3339(), location: NodeLocation { room: config.room.clone(), @@ -99,7 +99,7 @@ pub fn heartbeat_snapshot(inventory: &NodeInventory) -> NodeInventory { let mut snapshot = inventory.clone(); snapshot.last_seen_at = now_rfc3339(); - snapshot.runtime = runtime_details(&snapshot.interfaces); + snapshot.runtime = runtime::runtime_details(&snapshot.interfaces); snapshot } @@ -119,799 +119,3 @@ pub fn heartbeat_health_details( "status": heartbeat.status.as_str(), }) } - -fn discover_audio_interfaces(config: &AgentConfig) -> Vec { - let mut alsa_interfaces = discover_arecord_interfaces(&config.inventory_arecord_command); - - if alsa_interfaces.is_empty() { - alsa_interfaces = discover_proc_asound_interfaces( - &config.inventory_proc_asound_pcm_path, - &config.inventory_arecord_command, - ); - } - - if alsa_interfaces.is_empty() { - vec![fallback_interface()] - } else { - alsa_interfaces - } -} - -fn discover_arecord_interfaces(arecord_command: &str) -> Vec { - let Ok(output) = Command::new(arecord_command).arg("-l").output() else { - return Vec::new(); - }; - - if !output.status.success() { - return Vec::new(); - } - - let list_output = String::from_utf8_lossy(&output.stdout); - - parse_alsa_capture_devices(&list_output) - .into_iter() - .map(|device| { - let metadata = read_alsa_metadata(device.card, device.device, arecord_command); - let channel_count = metadata.channel_count.unwrap_or(2); - let sample_rates = if metadata.sample_rates.is_empty() { - vec![48_000] - } else { - metadata.sample_rates - }; - - AudioInterfaceInventory { - alias: device.system_name.clone(), - backend: "alsa".to_string(), - channel_count, - channels: channels(channel_count), - hardware_path: Some(alsa_hardware_path(device.card, device.device)), - sample_rates, - serial_number: alsa_serial_number(device.card), - id: alsa_interface_id(&device), - system_name: device.system_name, - system_ref: Some(device.system_ref), - } - }) - .collect() -} - -fn discover_proc_asound_interfaces( - path: &Path, - arecord_command: &str, -) -> Vec { - fs::read_to_string(path) - .map(|content| { - parse_proc_asound_pcm_devices(&content) - .into_iter() - .map(|device| { - let metadata = read_alsa_metadata(device.card, device.device, arecord_command); - let channel_count = metadata.channel_count.unwrap_or(2); - let sample_rates = if metadata.sample_rates.is_empty() { - vec![48_000] - } else { - metadata.sample_rates - }; - - AudioInterfaceInventory { - alias: device.system_name.clone(), - backend: "alsa".to_string(), - channel_count, - channels: channels(channel_count), - hardware_path: Some(alsa_hardware_path(device.card, device.device)), - id: format!("alsa_hw_{}_{}", device.card, device.device), - sample_rates, - serial_number: alsa_serial_number(device.card), - system_name: device.system_name, - system_ref: Some(device.system_ref), - } - }) - .collect() - }) - .unwrap_or_default() -} - -// The controller caps a heartbeat's `ipAddresses` at 16 (nodeHeartbeatSchema) -// and truncates an over-cap list rather than reject it. A well-behaved agent -// should never emit more than the cap in the first place, so bound the list -// here too (a multi-homed host — IPv6 SLAAC/privacy + Docker/libvirt/VLAN -// bridges — can exceed 16). See audit R7-IP-AGENT-CAP / R7-IPCAP. -const MAX_IP_ADDRESSES: usize = 16; - -fn collect_ip_addresses() -> Vec { - let Ok(output) = Command::new("hostname").arg("-I").output() else { - return Vec::new(); - }; - - if !output.status.success() { - return Vec::new(); - } - - parse_ip_addresses(&String::from_utf8_lossy(&output.stdout)) -} - -// Pure parser for `hostname -I` output: whitespace-split, bounded to the -// documented cap so the agent never sends a payload the controller would have -// to truncate. -fn parse_ip_addresses(stdout: &str) -> Vec { - stdout - .split_whitespace() - .take(MAX_IP_ADDRESSES) - .map(str::to_string) - .collect() -} - -fn fallback_interface() -> AudioInterfaceInventory { - AudioInterfaceInventory { - alias: "Default Capture Interface".to_string(), - backend: "unknown".to_string(), - channel_count: 2, - channels: channels(2), - hardware_path: None, - id: "iface_default_capture".to_string(), - sample_rates: vec![48_000], - serial_number: None, - system_name: "Audio backend discovery pending".to_string(), - system_ref: None, - } -} - -fn alsa_hardware_path(card: u16, device: u16) -> String { - canonical_path(format!("/sys/class/sound/card{card}/pcmC{card}D{device}c")) - .or_else(|| canonical_path(format!("/sys/class/sound/card{card}/device"))) - .unwrap_or_else(|| PathBuf::from(format!("/proc/asound/card{card}/pcm{device}c"))) - .to_string_lossy() - .to_string() -} - -fn alsa_interface_id(device: &AlsaCaptureDevice) -> String { - let Some(card_id) = alsa_card_id(&device.system_ref) else { - return format!("alsa_hw_{}_{}", device.card, device.device); - }; - let normalized = normalize_inventory_id_token(&card_id); - - if normalized.is_empty() { - format!("alsa_hw_{}_{}", device.card, device.device) - } else { - format!("alsa_card_{normalized}_dev_{}", device.device) - } -} - -fn alsa_card_id(system_ref: &str) -> Option { - system_ref - .strip_prefix("hw:")? - .split(',') - .find_map(|part| part.trim().strip_prefix("CARD=").map(str::to_string)) -} - -fn normalize_inventory_id_token(value: &str) -> String { - value - .chars() - .filter(|character| character.is_ascii_alphanumeric()) - .flat_map(char::to_lowercase) - .collect() -} - -fn alsa_serial_number(card: u16) -> Option { - let device_path = canonical_path(format!("/sys/class/sound/card{card}/device"))?; - - sysfs_serial_number(&device_path) -} - -fn sysfs_serial_number(path: &Path) -> Option { - path.ancestors().find_map(|candidate| { - read_trimmed(candidate.join("serial")) - .or_else(|| serial_from_uevent(&read_trimmed(candidate.join("uevent"))?)) - }) -} - -fn canonical_path(path: impl AsRef) -> Option { - fs::canonicalize(path).ok() -} - -fn read_trimmed(path: impl AsRef) -> Option { - let value = fs::read_to_string(path).ok()?.trim().to_string(); - - if value.is_empty() { None } else { Some(value) } -} - -fn serial_from_uevent(value: &str) -> Option { - value.lines().find_map(|line| { - let serial = line.strip_prefix("SERIAL=")?.trim(); - - if serial.is_empty() { - None - } else { - Some(serial.to_string()) - } - }) -} - -fn runtime_details(interfaces: &[AudioInterfaceInventory]) -> NodeRuntime { - NodeRuntime { - architecture: std::env::consts::ARCH.to_string(), - audio_backends: runtime_audio_backends(interfaces, command_available), - kernel_release: command_stdout("uname", &["-r"]), - os_name: linux_pretty_name().or_else(|| command_stdout("uname", &["-s"])), - uptime_seconds: linux_uptime_seconds(), - } -} - -fn runtime_audio_backends( - interfaces: &[AudioInterfaceInventory], - mut is_command_available: impl FnMut(&str) -> bool, -) -> Vec { - let mut audio_backends = interfaces - .iter() - .map(|audio_interface| audio_interface.backend.clone()) - .collect::>(); - - if is_command_available("pw-cli") || is_command_available("pipewire") { - audio_backends.push("pipewire".to_string()); - } - - if is_command_available("jackd") || is_command_available("jack_control") { - audio_backends.push("jack".to_string()); - } - - if audio_backends.iter().any(|backend| backend != "unknown") { - audio_backends.retain(|backend| backend != "unknown"); - } - - audio_backends.sort(); - audio_backends.dedup(); - audio_backends -} - -fn command_available(command: &str) -> bool { - let Some(paths) = env::var_os("PATH") else { - return false; - }; - let extensions = command_extensions(); - - env::split_paths(&paths).any(|path| { - extensions - .iter() - .map(|extension| path.join(format!("{command}{extension}"))) - .any(|candidate| candidate.is_file()) - }) -} - -fn command_extensions() -> Vec { - if cfg!(windows) { - env::var_os("PATHEXT") - .map(|value| { - let mut extensions = vec!["".to_string()]; - extensions.extend( - value - .to_string_lossy() - .split(';') - .map(str::trim) - .filter(|extension| !extension.is_empty()) - .map(str::to_string), - ); - - extensions - }) - .filter(|extensions| extensions.len() > 1) - .unwrap_or_else(|| { - vec![ - "".to_string(), - ".exe".to_string(), - ".cmd".to_string(), - ".bat".to_string(), - ] - }) - } else { - vec!["".to_string()] - } -} - -fn command_stdout(command: &str, args: &[&str]) -> Option { - let output = Command::new(command).args(args).output().ok()?; - - if !output.status.success() { - return None; - } - - let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); - - if value.is_empty() { None } else { Some(value) } -} - -fn linux_pretty_name() -> Option { - let content = fs::read_to_string("/etc/os-release").ok()?; - - content.lines().find_map(|line| { - let value = line.strip_prefix("PRETTY_NAME=")?; - - Some(value.trim_matches('"').to_string()) - }) -} - -fn linux_uptime_seconds() -> Option { - let content = fs::read_to_string("/proc/uptime").ok()?; - let first = content.split_whitespace().next()?; - let seconds = first.split('.').next()?.parse::().ok()?; - - Some(seconds) -} - -fn channels(channel_count: u16) -> Vec { - (1..=channel_count) - .map(|index| AudioChannelInventory { - alias: format!("Input {index}"), - index, - }) - .collect() -} - -#[derive(Debug, PartialEq, Eq)] -struct AlsaCaptureDevice { - card: u16, - device: u16, - system_ref: String, - system_name: String, -} - -fn parse_alsa_capture_devices(input: &str) -> Vec { - input - .lines() - .filter_map(parse_alsa_capture_device) - .collect() -} - -fn parse_proc_asound_pcm_devices(input: &str) -> Vec { - input - .lines() - .filter_map(parse_proc_asound_pcm_device) - .collect() -} - -fn parse_alsa_capture_device(line: &str) -> Option { - let after_card = line.trim().strip_prefix("card ")?; - let (card_text, after_card_number) = after_card.split_once(':')?; - let card = card_text.trim().parse::().ok()?; - let (card_label_text, after_device_marker) = after_card_number.split_once(", device ")?; - let (device_text, device_label_text) = after_device_marker.split_once(':')?; - let device = device_text.trim().parse::().ok()?; - let card_id = card_label_text.split_whitespace().next()?; - let card_label = bracket_label(card_label_text).unwrap_or_else(|| card_label_text.trim()); - let device_label = bracket_label(device_label_text).unwrap_or_else(|| device_label_text.trim()); - let system_name = if card_label.eq_ignore_ascii_case(device_label) { - device_label.to_string() - } else { - format!("{card_label} {device_label}") - }; - - Some(AlsaCaptureDevice { - card, - device, - system_ref: format!("hw:CARD={card_id},DEV={device}"), - system_name, - }) -} - -fn parse_proc_asound_pcm_device(line: &str) -> Option { - if !line.contains(": capture") { - return None; - } - - let (address, rest) = line.split_once(':')?; - let (card_text, device_text) = address.trim().split_once('-')?; - let card = card_text.trim().parse::().ok()?; - let device = device_text.trim().parse::().ok()?; - let system_name = rest - .split(':') - .map(str::trim) - .find(|part| { - !part.is_empty() && !part.starts_with("playback") && !part.starts_with("capture") - })? - .to_string(); - - Some(AlsaCaptureDevice { - card, - device, - system_ref: format!("hw:{card},{device}"), - system_name, - }) -} - -fn bracket_label(value: &str) -> Option<&str> { - let (_, after_open) = value.split_once('[')?; - let (label, _) = after_open.split_once(']')?; - Some(label.trim()) -} - -#[derive(Debug, Default, PartialEq, Eq)] -struct AlsaStreamMetadata { - channel_count: Option, - sample_rates: Vec, -} - -fn read_alsa_stream_metadata(card: u16) -> AlsaStreamMetadata { - fs::read_to_string(format!("/proc/asound/card{card}/stream0")) - .map(|content| parse_alsa_stream_metadata(&content)) - .unwrap_or_default() -} - -fn read_alsa_metadata(card: u16, device: u16, arecord_command: &str) -> AlsaStreamMetadata { - let metadata = read_alsa_stream_metadata(card); - - if metadata.is_complete() { - metadata - } else { - metadata.with_fallback(read_alsa_hw_params_metadata(card, device, arecord_command)) - } -} - -fn read_alsa_hw_params_metadata( - card: u16, - device: u16, - arecord_command: &str, -) -> AlsaStreamMetadata { - let Ok(output) = Command::new(arecord_command) - .arg("-D") - .arg(format!("hw:{card},{device}")) - .arg("--dump-hw-params") - .arg("-f") - .arg("S16_LE") - .arg("-r") - .arg("48000") - .arg("-c") - .arg("2") - .arg("-d") - .arg("1") - .arg("/dev/null") - .output() - else { - return AlsaStreamMetadata::default(); - }; - - if !output.status.success() { - return AlsaStreamMetadata::default(); - } - - let mut content = String::from_utf8_lossy(&output.stdout).to_string(); - content.push_str(&String::from_utf8_lossy(&output.stderr)); - - parse_alsa_hw_params_metadata(&content) -} - -fn parse_alsa_stream_metadata(input: &str) -> AlsaStreamMetadata { - let mut metadata = AlsaStreamMetadata::default(); - let mut in_capture = false; - - for line in input.lines() { - let trimmed = line.trim(); - - if trimmed == "Capture:" { - in_capture = true; - continue; - } - - if trimmed.ends_with(':') && trimmed != "Capture:" { - in_capture = false; - } - - if !in_capture { - continue; - } - - if let Some(value) = trimmed.strip_prefix("Channels:") { - metadata.channel_count = parse_numbers::(value).into_iter().max(); - } else if let Some(value) = trimmed.strip_prefix("Rates:") { - metadata.sample_rates = parse_numbers(value); - } - } - - metadata.sample_rates.sort_unstable(); - metadata.sample_rates.dedup(); - metadata -} - -fn parse_alsa_hw_params_metadata(input: &str) -> AlsaStreamMetadata { - let mut metadata = AlsaStreamMetadata::default(); - - for line in input.lines() { - let trimmed = line.trim(); - - if let Some(value) = trimmed.strip_prefix("CHANNELS:") { - metadata.channel_count = parse_numbers::(value).into_iter().max(); - } else if let Some(value) = trimmed.strip_prefix("RATE:") { - metadata.sample_rates = parse_numbers(value); - } - } - - metadata.sample_rates.sort_unstable(); - metadata.sample_rates.dedup(); - metadata -} - -impl AlsaStreamMetadata { - fn is_complete(&self) -> bool { - self.channel_count.is_some() && !self.sample_rates.is_empty() - } - - fn with_fallback(mut self, fallback: Self) -> Self { - if self.channel_count.is_none() { - self.channel_count = fallback.channel_count; - } - - if self.sample_rates.is_empty() { - self.sample_rates = fallback.sample_rates; - } - - self - } -} - -fn parse_numbers(value: &str) -> Vec -where - T: std::str::FromStr, -{ - value - .split(|character: char| !character.is_ascii_digit()) - .filter(|part| !part.is_empty()) - .filter_map(|part| part.parse::().ok()) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_ip_addresses_and_caps_at_the_documented_limit() { - // A normal multi-address host parses to a trimmed list. - assert_eq!( - parse_ip_addresses("192.168.1.10 10.0.0.5 \n"), - vec!["192.168.1.10".to_string(), "10.0.0.5".to_string()], - ); - - // A host with more than the cap (e.g. IPv6 SLAAC/privacy + bridges) is - // bounded to MAX_IP_ADDRESSES so the controller never has to truncate. - let many = (0..40) - .map(|n| format!("10.0.0.{n}")) - .collect::>() - .join(" "); - let parsed = parse_ip_addresses(&many); - - assert_eq!(parsed.len(), MAX_IP_ADDRESSES); - assert_eq!(parsed.first().map(String::as_str), Some("10.0.0.0")); - assert_eq!(parsed.last().map(String::as_str), Some("10.0.0.15")); - - // Empty output yields no addresses. - assert!(parse_ip_addresses(" \n").is_empty()); - } - - #[test] - fn parses_arecord_capture_devices() { - let devices = parse_alsa_capture_devices( - r#" -**** List of CAPTURE Hardware Devices **** -card 2: XUSB [X-USB], device 0: USB Audio [USB Audio] - Subdevices: 1/1 - Subdevice #0: subdevice #0 -"#, - ); - - assert_eq!( - devices, - vec![AlsaCaptureDevice { - card: 2, - device: 0, - system_ref: "hw:CARD=XUSB,DEV=0".to_string(), - system_name: "X-USB USB Audio".to_string(), - }] - ); - } - - #[test] - fn stable_alsa_interface_id_uses_card_name_when_available() { - let device = AlsaCaptureDevice { - card: 7, - device: 0, - system_ref: "hw:CARD=SMOKE,DEV=0".to_string(), - system_name: "Smoke Audio Capture".to_string(), - }; - - assert_eq!(alsa_interface_id(&device), "alsa_card_smoke_dev_0"); - } - - #[test] - fn parses_capture_stream_metadata() { - let metadata = parse_alsa_stream_metadata( - r#" -Playback: - Channels: 2 - Rates: 48000 -Capture: - Channels: 32 - Rates: 44100, 48000 -"#, - ); - - assert_eq!( - metadata, - AlsaStreamMetadata { - channel_count: Some(32), - sample_rates: vec![44_100, 48_000], - } - ); - } - - #[test] - fn parses_arecord_hw_params_metadata() { - let metadata = parse_alsa_hw_params_metadata( - r#" -HW Params of device "hw:1,0": --------------------- -ACCESS: MMAP_INTERLEAVED RW_INTERLEAVED -FORMAT: S16_LE S24_LE S32_LE -CHANNELS: [1 32] -RATE: [44100 96000] -PERIOD_SIZE: [32 8192] -"#, - ); - - assert_eq!( - metadata, - AlsaStreamMetadata { - channel_count: Some(32), - sample_rates: vec![44_100, 96_000], - } - ); - } - - #[test] - fn keeps_proc_stream_metadata_before_hw_params_fallback() { - let metadata = AlsaStreamMetadata { - channel_count: Some(32), - sample_rates: vec![48_000], - } - .with_fallback(AlsaStreamMetadata { - channel_count: Some(2), - sample_rates: vec![44_100], - }); - - assert_eq!( - metadata, - AlsaStreamMetadata { - channel_count: Some(32), - sample_rates: vec![48_000], - } - ); - } - - #[test] - fn fills_missing_stream_metadata_from_hw_params_fallback() { - let metadata = AlsaStreamMetadata { - channel_count: None, - sample_rates: Vec::new(), - } - .with_fallback(AlsaStreamMetadata { - channel_count: Some(8), - sample_rates: vec![44_100, 48_000], - }); - - assert_eq!( - metadata, - AlsaStreamMetadata { - channel_count: Some(8), - sample_rates: vec![44_100, 48_000], - } - ); - } - - #[test] - fn parses_proc_asound_pcm_capture_devices() { - let devices = parse_proc_asound_pcm_devices( - r#" -00-00: ALC256 Analog : ALC256 Analog : playback 1 : capture 1 -00-03: HDMI 0 : HDMI 0 : playback 1 -01-00: USB Audio : USB Audio : capture 1 -"#, - ); - - assert_eq!( - devices, - vec![ - AlsaCaptureDevice { - card: 0, - device: 0, - system_ref: "hw:0,0".to_string(), - system_name: "ALC256 Analog".to_string(), - }, - AlsaCaptureDevice { - card: 1, - device: 0, - system_ref: "hw:1,0".to_string(), - system_name: "USB Audio".to_string(), - }, - ] - ); - } - - #[test] - fn runtime_audio_backends_include_available_pipewire_and_jack() { - let interfaces = vec![AudioInterfaceInventory { - alias: "USB".to_string(), - backend: "alsa".to_string(), - channel_count: 2, - channels: Vec::new(), - hardware_path: None, - id: "alsa_hw_1_0".to_string(), - sample_rates: vec![48_000], - serial_number: None, - system_name: "USB Audio".to_string(), - system_ref: Some("hw:1,0".to_string()), - }]; - - assert_eq!( - runtime_audio_backends(&interfaces, |command| matches!(command, "pw-cli" | "jackd")), - vec!["alsa", "jack", "pipewire"] - ); - } - - #[test] - fn runtime_audio_backends_keep_unknown_fallback() { - assert_eq!( - runtime_audio_backends(&[fallback_interface()], |_| false), - vec!["unknown"] - ); - } - - #[test] - fn runtime_audio_backends_drop_unknown_when_real_backend_available() { - assert_eq!( - runtime_audio_backends(&[fallback_interface()], |command| command == "pipewire"), - vec!["pipewire"] - ); - } - - #[test] - fn runtime_audio_backends_dedupe_and_sort() { - let interfaces = vec![ - AudioInterfaceInventory { - alias: "A".to_string(), - backend: "alsa".to_string(), - channel_count: 2, - channels: Vec::new(), - hardware_path: None, - id: "a".to_string(), - sample_rates: vec![48_000], - serial_number: None, - system_name: "A".to_string(), - system_ref: Some("hw:1,0".to_string()), - }, - AudioInterfaceInventory { - alias: "B".to_string(), - backend: "alsa".to_string(), - channel_count: 2, - channels: Vec::new(), - hardware_path: None, - id: "b".to_string(), - sample_rates: vec![48_000], - serial_number: None, - system_name: "B".to_string(), - system_ref: Some("hw:2,0".to_string()), - }, - ]; - - assert_eq!(runtime_audio_backends(&interfaces, |_| false), vec!["alsa"]); - } - - #[test] - fn parses_serial_from_sysfs_uevent() { - assert_eq!( - serial_from_uevent( - r#" -PRODUCT=1397/50/100 -SERIAL=x32-rack-usb-serial -TYPE=0/0/0 -"# - ), - Some("x32-rack-usb-serial".to_string()) - ); - } -} diff --git a/crates/recorder-agent/src/inventory/alsa.rs b/crates/recorder-agent/src/inventory/alsa.rs new file mode 100644 index 0000000..261a950 --- /dev/null +++ b/crates/recorder-agent/src/inventory/alsa.rs @@ -0,0 +1,570 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::config::AgentConfig; + +use super::{AudioChannelInventory, AudioInterfaceInventory}; + +pub(crate) fn discover_audio_interfaces(config: &AgentConfig) -> Vec { + let mut alsa_interfaces = discover_arecord_interfaces(&config.inventory_arecord_command); + + if alsa_interfaces.is_empty() { + alsa_interfaces = discover_proc_asound_interfaces( + &config.inventory_proc_asound_pcm_path, + &config.inventory_arecord_command, + ); + } + + if alsa_interfaces.is_empty() { + vec![fallback_interface()] + } else { + alsa_interfaces + } +} + +fn discover_arecord_interfaces(arecord_command: &str) -> Vec { + let Ok(output) = Command::new(arecord_command).arg("-l").output() else { + return Vec::new(); + }; + + if !output.status.success() { + return Vec::new(); + } + + let list_output = String::from_utf8_lossy(&output.stdout); + + parse_alsa_capture_devices(&list_output) + .into_iter() + .map(|device| { + let metadata = read_alsa_metadata(device.card, device.device, arecord_command); + let channel_count = metadata.channel_count.unwrap_or(2); + let sample_rates = if metadata.sample_rates.is_empty() { + vec![48_000] + } else { + metadata.sample_rates + }; + + AudioInterfaceInventory { + alias: device.system_name.clone(), + backend: "alsa".to_string(), + channel_count, + channels: channels(channel_count), + hardware_path: Some(alsa_hardware_path(device.card, device.device)), + sample_rates, + serial_number: alsa_serial_number(device.card), + id: alsa_interface_id(&device), + system_name: device.system_name, + system_ref: Some(device.system_ref), + } + }) + .collect() +} + +fn discover_proc_asound_interfaces( + path: &Path, + arecord_command: &str, +) -> Vec { + fs::read_to_string(path) + .map(|content| { + parse_proc_asound_pcm_devices(&content) + .into_iter() + .map(|device| { + let metadata = read_alsa_metadata(device.card, device.device, arecord_command); + let channel_count = metadata.channel_count.unwrap_or(2); + let sample_rates = if metadata.sample_rates.is_empty() { + vec![48_000] + } else { + metadata.sample_rates + }; + + AudioInterfaceInventory { + alias: device.system_name.clone(), + backend: "alsa".to_string(), + channel_count, + channels: channels(channel_count), + hardware_path: Some(alsa_hardware_path(device.card, device.device)), + id: format!("alsa_hw_{}_{}", device.card, device.device), + sample_rates, + serial_number: alsa_serial_number(device.card), + system_name: device.system_name, + system_ref: Some(device.system_ref), + } + }) + .collect() + }) + .unwrap_or_default() +} + +pub(crate) fn fallback_interface() -> AudioInterfaceInventory { + AudioInterfaceInventory { + alias: "Default Capture Interface".to_string(), + backend: "unknown".to_string(), + channel_count: 2, + channels: channels(2), + hardware_path: None, + id: "iface_default_capture".to_string(), + sample_rates: vec![48_000], + serial_number: None, + system_name: "Audio backend discovery pending".to_string(), + system_ref: None, + } +} + +fn channels(channel_count: u16) -> Vec { + (1..=channel_count) + .map(|index| AudioChannelInventory { + alias: format!("Input {index}"), + index, + }) + .collect() +} + +fn alsa_hardware_path(card: u16, device: u16) -> String { + canonical_path(format!("/sys/class/sound/card{card}/pcmC{card}D{device}c")) + .or_else(|| canonical_path(format!("/sys/class/sound/card{card}/device"))) + .unwrap_or_else(|| PathBuf::from(format!("/proc/asound/card{card}/pcm{device}c"))) + .to_string_lossy() + .to_string() +} + +fn alsa_interface_id(device: &AlsaCaptureDevice) -> String { + let Some(card_id) = alsa_card_id(&device.system_ref) else { + return format!("alsa_hw_{}_{}", device.card, device.device); + }; + let normalized = normalize_inventory_id_token(&card_id); + + if normalized.is_empty() { + format!("alsa_hw_{}_{}", device.card, device.device) + } else { + format!("alsa_card_{normalized}_dev_{}", device.device) + } +} + +fn alsa_card_id(system_ref: &str) -> Option { + system_ref + .strip_prefix("hw:")? + .split(',') + .find_map(|part| part.trim().strip_prefix("CARD=").map(str::to_string)) +} + +fn normalize_inventory_id_token(value: &str) -> String { + value + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn alsa_serial_number(card: u16) -> Option { + let device_path = canonical_path(format!("/sys/class/sound/card{card}/device"))?; + + sysfs_serial_number(&device_path) +} + +fn sysfs_serial_number(path: &Path) -> Option { + path.ancestors().find_map(|candidate| { + read_trimmed(candidate.join("serial")) + .or_else(|| serial_from_uevent(&read_trimmed(candidate.join("uevent"))?)) + }) +} + +fn canonical_path(path: impl AsRef) -> Option { + fs::canonicalize(path).ok() +} + +fn read_trimmed(path: impl AsRef) -> Option { + let value = fs::read_to_string(path).ok()?.trim().to_string(); + + if value.is_empty() { None } else { Some(value) } +} + +fn serial_from_uevent(value: &str) -> Option { + value.lines().find_map(|line| { + let serial = line.strip_prefix("SERIAL=")?.trim(); + + if serial.is_empty() { + None + } else { + Some(serial.to_string()) + } + }) +} + +#[derive(Debug, PartialEq, Eq)] +struct AlsaCaptureDevice { + card: u16, + device: u16, + system_ref: String, + system_name: String, +} + +fn parse_alsa_capture_devices(input: &str) -> Vec { + input + .lines() + .filter_map(parse_alsa_capture_device) + .collect() +} + +fn parse_proc_asound_pcm_devices(input: &str) -> Vec { + input + .lines() + .filter_map(parse_proc_asound_pcm_device) + .collect() +} + +fn parse_alsa_capture_device(line: &str) -> Option { + let after_card = line.trim().strip_prefix("card ")?; + let (card_text, after_card_number) = after_card.split_once(':')?; + let card = card_text.trim().parse::().ok()?; + let (card_label_text, after_device_marker) = after_card_number.split_once(", device ")?; + let (device_text, device_label_text) = after_device_marker.split_once(':')?; + let device = device_text.trim().parse::().ok()?; + let card_id = card_label_text.split_whitespace().next()?; + let card_label = bracket_label(card_label_text).unwrap_or_else(|| card_label_text.trim()); + let device_label = bracket_label(device_label_text).unwrap_or_else(|| device_label_text.trim()); + let system_name = if card_label.eq_ignore_ascii_case(device_label) { + device_label.to_string() + } else { + format!("{card_label} {device_label}") + }; + + Some(AlsaCaptureDevice { + card, + device, + system_ref: format!("hw:CARD={card_id},DEV={device}"), + system_name, + }) +} + +fn parse_proc_asound_pcm_device(line: &str) -> Option { + if !line.contains(": capture") { + return None; + } + + let (address, rest) = line.split_once(':')?; + let (card_text, device_text) = address.trim().split_once('-')?; + let card = card_text.trim().parse::().ok()?; + let device = device_text.trim().parse::().ok()?; + let system_name = rest + .split(':') + .map(str::trim) + .find(|part| { + !part.is_empty() && !part.starts_with("playback") && !part.starts_with("capture") + })? + .to_string(); + + Some(AlsaCaptureDevice { + card, + device, + system_ref: format!("hw:{card},{device}"), + system_name, + }) +} + +fn bracket_label(value: &str) -> Option<&str> { + let (_, after_open) = value.split_once('[')?; + let (label, _) = after_open.split_once(']')?; + Some(label.trim()) +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct AlsaStreamMetadata { + channel_count: Option, + sample_rates: Vec, +} + +fn read_alsa_stream_metadata(card: u16) -> AlsaStreamMetadata { + fs::read_to_string(format!("/proc/asound/card{card}/stream0")) + .map(|content| parse_alsa_stream_metadata(&content)) + .unwrap_or_default() +} + +fn read_alsa_metadata(card: u16, device: u16, arecord_command: &str) -> AlsaStreamMetadata { + let metadata = read_alsa_stream_metadata(card); + + if metadata.is_complete() { + metadata + } else { + metadata.with_fallback(read_alsa_hw_params_metadata(card, device, arecord_command)) + } +} + +fn read_alsa_hw_params_metadata( + card: u16, + device: u16, + arecord_command: &str, +) -> AlsaStreamMetadata { + let Ok(output) = Command::new(arecord_command) + .arg("-D") + .arg(format!("hw:{card},{device}")) + .arg("--dump-hw-params") + .arg("-f") + .arg("S16_LE") + .arg("-r") + .arg("48000") + .arg("-c") + .arg("2") + .arg("-d") + .arg("1") + .arg("/dev/null") + .output() + else { + return AlsaStreamMetadata::default(); + }; + + if !output.status.success() { + return AlsaStreamMetadata::default(); + } + + let mut content = String::from_utf8_lossy(&output.stdout).to_string(); + content.push_str(&String::from_utf8_lossy(&output.stderr)); + + parse_alsa_hw_params_metadata(&content) +} + +fn parse_alsa_stream_metadata(input: &str) -> AlsaStreamMetadata { + let mut metadata = AlsaStreamMetadata::default(); + let mut in_capture = false; + + for line in input.lines() { + let trimmed = line.trim(); + + if trimmed == "Capture:" { + in_capture = true; + continue; + } + + if trimmed.ends_with(':') && trimmed != "Capture:" { + in_capture = false; + } + + if !in_capture { + continue; + } + + if let Some(value) = trimmed.strip_prefix("Channels:") { + metadata.channel_count = parse_numbers::(value).into_iter().max(); + } else if let Some(value) = trimmed.strip_prefix("Rates:") { + metadata.sample_rates = parse_numbers(value); + } + } + + metadata.sample_rates.sort_unstable(); + metadata.sample_rates.dedup(); + metadata +} + +fn parse_alsa_hw_params_metadata(input: &str) -> AlsaStreamMetadata { + let mut metadata = AlsaStreamMetadata::default(); + + for line in input.lines() { + let trimmed = line.trim(); + + if let Some(value) = trimmed.strip_prefix("CHANNELS:") { + metadata.channel_count = parse_numbers::(value).into_iter().max(); + } else if let Some(value) = trimmed.strip_prefix("RATE:") { + metadata.sample_rates = parse_numbers(value); + } + } + + metadata.sample_rates.sort_unstable(); + metadata.sample_rates.dedup(); + metadata +} + +impl AlsaStreamMetadata { + fn is_complete(&self) -> bool { + self.channel_count.is_some() && !self.sample_rates.is_empty() + } + + fn with_fallback(mut self, fallback: Self) -> Self { + if self.channel_count.is_none() { + self.channel_count = fallback.channel_count; + } + + if self.sample_rates.is_empty() { + self.sample_rates = fallback.sample_rates; + } + + self + } +} + +fn parse_numbers(value: &str) -> Vec +where + T: std::str::FromStr, +{ + value + .split(|character: char| !character.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse::().ok()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_arecord_capture_devices() { + let devices = parse_alsa_capture_devices( + r#" +**** List of CAPTURE Hardware Devices **** +card 2: XUSB [X-USB], device 0: USB Audio [USB Audio] + Subdevices: 1/1 + Subdevice #0: subdevice #0 +"#, + ); + + assert_eq!( + devices, + vec![AlsaCaptureDevice { + card: 2, + device: 0, + system_ref: "hw:CARD=XUSB,DEV=0".to_string(), + system_name: "X-USB USB Audio".to_string(), + }] + ); + } + + #[test] + fn stable_alsa_interface_id_uses_card_name_when_available() { + let device = AlsaCaptureDevice { + card: 7, + device: 0, + system_ref: "hw:CARD=SMOKE,DEV=0".to_string(), + system_name: "Smoke Audio Capture".to_string(), + }; + + assert_eq!(alsa_interface_id(&device), "alsa_card_smoke_dev_0"); + } + + #[test] + fn parses_capture_stream_metadata() { + let metadata = parse_alsa_stream_metadata( + r#" +Playback: + Channels: 2 + Rates: 48000 +Capture: + Channels: 32 + Rates: 44100, 48000 +"#, + ); + + assert_eq!( + metadata, + AlsaStreamMetadata { + channel_count: Some(32), + sample_rates: vec![44_100, 48_000], + } + ); + } + + #[test] + fn parses_arecord_hw_params_metadata() { + let metadata = parse_alsa_hw_params_metadata( + r#" +HW Params of device "hw:1,0": +-------------------- +ACCESS: MMAP_INTERLEAVED RW_INTERLEAVED +FORMAT: S16_LE S24_LE S32_LE +CHANNELS: [1 32] +RATE: [44100 96000] +PERIOD_SIZE: [32 8192] +"#, + ); + + assert_eq!( + metadata, + AlsaStreamMetadata { + channel_count: Some(32), + sample_rates: vec![44_100, 96_000], + } + ); + } + + #[test] + fn keeps_proc_stream_metadata_before_hw_params_fallback() { + let metadata = AlsaStreamMetadata { + channel_count: Some(32), + sample_rates: vec![48_000], + } + .with_fallback(AlsaStreamMetadata { + channel_count: Some(2), + sample_rates: vec![44_100], + }); + + assert_eq!( + metadata, + AlsaStreamMetadata { + channel_count: Some(32), + sample_rates: vec![48_000], + } + ); + } + + #[test] + fn fills_missing_stream_metadata_from_hw_params_fallback() { + let metadata = AlsaStreamMetadata { + channel_count: None, + sample_rates: Vec::new(), + } + .with_fallback(AlsaStreamMetadata { + channel_count: Some(8), + sample_rates: vec![44_100, 48_000], + }); + + assert_eq!( + metadata, + AlsaStreamMetadata { + channel_count: Some(8), + sample_rates: vec![44_100, 48_000], + } + ); + } + + #[test] + fn parses_proc_asound_pcm_capture_devices() { + let devices = parse_proc_asound_pcm_devices( + r#" +00-00: ALC256 Analog : ALC256 Analog : playback 1 : capture 1 +00-03: HDMI 0 : HDMI 0 : playback 1 +01-00: USB Audio : USB Audio : capture 1 +"#, + ); + + assert_eq!( + devices, + vec![ + AlsaCaptureDevice { + card: 0, + device: 0, + system_ref: "hw:0,0".to_string(), + system_name: "ALC256 Analog".to_string(), + }, + AlsaCaptureDevice { + card: 1, + device: 0, + system_ref: "hw:1,0".to_string(), + system_name: "USB Audio".to_string(), + }, + ] + ); + } + + #[test] + fn parses_serial_from_sysfs_uevent() { + assert_eq!( + serial_from_uevent( + r#" +PRODUCT=1397/50/100 +SERIAL=x32-rack-usb-serial +TYPE=0/0/0 +"# + ), + Some("x32-rack-usb-serial".to_string()) + ); + } +} diff --git a/crates/recorder-agent/src/inventory/net.rs b/crates/recorder-agent/src/inventory/net.rs new file mode 100644 index 0000000..54b6caf --- /dev/null +++ b/crates/recorder-agent/src/inventory/net.rs @@ -0,0 +1,60 @@ +use std::process::Command; + +// The controller caps a heartbeat's `ipAddresses` at 16 (nodeHeartbeatSchema) +// and truncates an over-cap list rather than reject it. A well-behaved agent +// should never emit more than the cap in the first place, so bound the list +// here too (a multi-homed host — IPv6 SLAAC/privacy + Docker/libvirt/VLAN +// bridges — can exceed 16). See audit R7-IP-AGENT-CAP / R7-IPCAP. +const MAX_IP_ADDRESSES: usize = 16; + +pub(crate) fn collect_ip_addresses() -> Vec { + let Ok(output) = Command::new("hostname").arg("-I").output() else { + return Vec::new(); + }; + + if !output.status.success() { + return Vec::new(); + } + + parse_ip_addresses(&String::from_utf8_lossy(&output.stdout)) +} + +// Pure parser for `hostname -I` output: whitespace-split, bounded to the +// documented cap so the agent never sends a payload the controller would have +// to truncate. +fn parse_ip_addresses(stdout: &str) -> Vec { + stdout + .split_whitespace() + .take(MAX_IP_ADDRESSES) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_ip_addresses_and_caps_at_the_documented_limit() { + // A normal multi-address host parses to a trimmed list. + assert_eq!( + parse_ip_addresses("192.168.1.10 10.0.0.5 \n"), + vec!["192.168.1.10".to_string(), "10.0.0.5".to_string()], + ); + + // A host with more than the cap (e.g. IPv6 SLAAC/privacy + bridges) is + // bounded to MAX_IP_ADDRESSES so the controller never has to truncate. + let many = (0..40) + .map(|n| format!("10.0.0.{n}")) + .collect::>() + .join(" "); + let parsed = parse_ip_addresses(&many); + + assert_eq!(parsed.len(), MAX_IP_ADDRESSES); + assert_eq!(parsed.first().map(String::as_str), Some("10.0.0.0")); + assert_eq!(parsed.last().map(String::as_str), Some("10.0.0.15")); + + // Empty output yields no addresses. + assert!(parse_ip_addresses(" \n").is_empty()); + } +} diff --git a/crates/recorder-agent/src/inventory/runtime.rs b/crates/recorder-agent/src/inventory/runtime.rs new file mode 100644 index 0000000..d3e338b --- /dev/null +++ b/crates/recorder-agent/src/inventory/runtime.rs @@ -0,0 +1,190 @@ +use std::env; +use std::fs; +use std::process::Command; + +use super::{AudioInterfaceInventory, NodeRuntime}; + +pub(crate) fn runtime_details(interfaces: &[AudioInterfaceInventory]) -> NodeRuntime { + NodeRuntime { + architecture: env::consts::ARCH.to_string(), + audio_backends: runtime_audio_backends(interfaces, command_available), + kernel_release: command_stdout("uname", &["-r"]), + os_name: linux_pretty_name().or_else(|| command_stdout("uname", &["-s"])), + uptime_seconds: linux_uptime_seconds(), + } +} + +fn runtime_audio_backends( + interfaces: &[AudioInterfaceInventory], + mut is_command_available: impl FnMut(&str) -> bool, +) -> Vec { + let mut audio_backends = interfaces + .iter() + .map(|audio_interface| audio_interface.backend.clone()) + .collect::>(); + + if is_command_available("pw-cli") || is_command_available("pipewire") { + audio_backends.push("pipewire".to_string()); + } + + if is_command_available("jackd") || is_command_available("jack_control") { + audio_backends.push("jack".to_string()); + } + + if audio_backends.iter().any(|backend| backend != "unknown") { + audio_backends.retain(|backend| backend != "unknown"); + } + + audio_backends.sort(); + audio_backends.dedup(); + audio_backends +} + +fn command_available(command: &str) -> bool { + let Some(paths) = env::var_os("PATH") else { + return false; + }; + let extensions = command_extensions(); + + env::split_paths(&paths).any(|path| { + extensions + .iter() + .map(|extension| path.join(format!("{command}{extension}"))) + .any(|candidate| candidate.is_file()) + }) +} + +fn command_extensions() -> Vec { + if cfg!(windows) { + env::var_os("PATHEXT") + .map(|value| { + let mut extensions = vec!["".to_string()]; + extensions.extend( + value + .to_string_lossy() + .split(';') + .map(str::trim) + .filter(|extension| !extension.is_empty()) + .map(str::to_string), + ); + + extensions + }) + .filter(|extensions| extensions.len() > 1) + .unwrap_or_else(|| { + vec![ + "".to_string(), + ".exe".to_string(), + ".cmd".to_string(), + ".bat".to_string(), + ] + }) + } else { + vec!["".to_string()] + } +} + +fn command_stdout(command: &str, args: &[&str]) -> Option { + let output = Command::new(command).args(args).output().ok()?; + + if !output.status.success() { + return None; + } + + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if value.is_empty() { None } else { Some(value) } +} + +fn linux_pretty_name() -> Option { + let content = fs::read_to_string("/etc/os-release").ok()?; + + content.lines().find_map(|line| { + let value = line.strip_prefix("PRETTY_NAME=")?; + + Some(value.trim_matches('"').to_string()) + }) +} + +fn linux_uptime_seconds() -> Option { + let content = fs::read_to_string("/proc/uptime").ok()?; + let first = content.split_whitespace().next()?; + let seconds = first.split('.').next()?.parse::().ok()?; + + Some(seconds) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inventory::alsa::fallback_interface; + + #[test] + fn runtime_audio_backends_include_available_pipewire_and_jack() { + let interfaces = vec![AudioInterfaceInventory { + alias: "USB".to_string(), + backend: "alsa".to_string(), + channel_count: 2, + channels: Vec::new(), + hardware_path: None, + id: "alsa_hw_1_0".to_string(), + sample_rates: vec![48_000], + serial_number: None, + system_name: "USB Audio".to_string(), + system_ref: Some("hw:1,0".to_string()), + }]; + + assert_eq!( + runtime_audio_backends(&interfaces, |command| matches!(command, "pw-cli" | "jackd")), + vec!["alsa", "jack", "pipewire"] + ); + } + + #[test] + fn runtime_audio_backends_keep_unknown_fallback() { + assert_eq!( + runtime_audio_backends(&[fallback_interface()], |_| false), + vec!["unknown"] + ); + } + + #[test] + fn runtime_audio_backends_drop_unknown_when_real_backend_available() { + assert_eq!( + runtime_audio_backends(&[fallback_interface()], |command| command == "pipewire"), + vec!["pipewire"] + ); + } + + #[test] + fn runtime_audio_backends_dedupe_and_sort() { + let interfaces = vec![ + AudioInterfaceInventory { + alias: "A".to_string(), + backend: "alsa".to_string(), + channel_count: 2, + channels: Vec::new(), + hardware_path: None, + id: "a".to_string(), + sample_rates: vec![48_000], + serial_number: None, + system_name: "A".to_string(), + system_ref: Some("hw:1,0".to_string()), + }, + AudioInterfaceInventory { + alias: "B".to_string(), + backend: "alsa".to_string(), + channel_count: 2, + channels: Vec::new(), + hardware_path: None, + id: "b".to_string(), + sample_rates: vec![48_000], + serial_number: None, + system_name: "B".to_string(), + system_ref: Some("hw:2,0".to_string()), + }, + ]; + + assert_eq!(runtime_audio_backends(&interfaces, |_| false), vec!["alsa"]); + } +} diff --git a/docs/internal/baselines/FIRST_RELIABLE_RECORDING_BASELINE.md b/docs/internal/baselines/FIRST_RELIABLE_RECORDING_BASELINE.md index 9d928b7..1dc4cf3 100644 --- a/docs/internal/baselines/FIRST_RELIABLE_RECORDING_BASELINE.md +++ b/docs/internal/baselines/FIRST_RELIABLE_RECORDING_BASELINE.md @@ -24,7 +24,7 @@ Status: MVP baseline checked. | Check | Evidence | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Ad-hoc start metadata, profile, upload policy, capture targeting, and capacity conflict handling | `apps/api/test/recording-start-routes.test.ts`, `apps/api/test/recording-routes.test.ts` | -| Ad-hoc claim, claim-next, heartbeat, cache attach, duplicate attach idempotency, playback, download, stream, file | `apps/api/test/agent-routes.test.ts`, `apps/api/test/agent-cache-idempotency-routes.test.ts` | +| Ad-hoc claim, claim-next, heartbeat, cache attach, duplicate attach idempotency, playback, download, stream, file | `apps/api/test/agent-routes.test.ts`, `apps/api/test/agent-routes-recording-lifecycle.test.ts`, `apps/api/test/agent-cache-idempotency-routes.test.ts` | | Scheduled due-run metadata, claim, cache attach, playback, download, stream, file | `apps/api/test/schedule-runner.test.ts` | | Cache checksum, duration, waveform preview, file size | `apps/api/test/recording-cache.test.ts` | | Failed/cancelled/lease-expired job health transitions | `apps/api/test/agent-routes.test.ts`, `apps/api/test/recording-job-lease-runner.test.ts` | @@ -32,6 +32,6 @@ Status: MVP baseline checked. | Agent capture/render/cache/channel-map application/retention/stop/controller-terminal/claim-next-status-poll-control-plane-channel-map failure/controller-capacity/concurrency smoke and direct capture output encoding | `scripts/agent-fake-controller-smoke.mjs`, `scripts/agent-fake-controller-smoke-recovery.mjs`, `scripts/agent-fake-controller-smoke-support.mjs`, `crates/recorder-agent/src/capture.rs`, `crates/recorder-agent/src/config.rs`, `crates/recorder-agent/src/main.rs`, `crates/recorder-agent/src/inventory.rs`, `crates/recorder-agent/src/recording_job_disk.rs`, `crates/recorder-agent/src/recording_job_recovery.rs`, `crates/recorder-agent/src/recording_job_segments.rs`, `crates/recorder-agent/src/recording_job_upload.rs`, `crates/recorder-agent/src/recorder_cache_retention.rs` | | Playback/download UI readiness and cleanup | `apps/web/src/lib/recording-page-helpers.test.ts` | | Schedule detail playback/download controls | `apps/web/src/lib/schedule-detail-page-helpers.test.ts` | -| Recording jobs workbench, export, stop, and retry controls | `apps/api/test/recording-job-export.test.ts`, `apps/api/test/recording-jobs.test.ts`, `apps/web/src/pages/jobs.tsx`, `apps/web/src/lib/jobs-page-helpers.test.ts`, `apps/web/src/lib/root-layout-helpers.test.ts` | +| Recording jobs workbench, export, stop, and retry controls | `apps/api/test/recording-job-export.test.ts`, `apps/api/test/recording-job-export-read.test.ts`, `apps/api/test/recording-job-export-lifecycle.test.ts`, `apps/api/test/recording-jobs.test.ts`, `apps/web/src/pages/jobs.tsx`, `apps/web/src/lib/jobs-page-helpers.test.ts`, `apps/web/src/lib/root-layout-helpers.test.ts` | `mise run recordings:check-first-reliable` validates this baseline, and `mise run check` runs it. diff --git a/docs/internal/baselines/GENERIC_DEVICE_BASELINE.md b/docs/internal/baselines/GENERIC_DEVICE_BASELINE.md index 8aec831..28e3628 100644 --- a/docs/internal/baselines/GENERIC_DEVICE_BASELINE.md +++ b/docs/internal/baselines/GENERIC_DEVICE_BASELINE.md @@ -53,9 +53,9 @@ jobs, onboard HDA jobs, and PipeWire/JACK command presets. | Controller-managed node audio defaults, node backend filtering, ad-hoc/schedule interface selection, and managed backend selection | `packages/shared/src/index.ts`, `apps/api/src/node-store.ts`, `apps/api/src/node-inventory-routes.ts`, `apps/api/src/node-routes.ts`, `apps/api/src/agent-node-config-route.ts`, `apps/api/src/recording-routes.ts`, `apps/api/src/recording-job-targets.ts`, `apps/api/src/recording-jobs.ts`, `apps/api/src/scheduled-recordings.ts`, `apps/api/test/recording-start-routes.test.ts`, `apps/api/test/recording-jobs.test.ts`, `apps/api/test/schedule-runner.test.ts`, `apps/api/test/node-inventory-routes.test.ts`, `apps/api/test/node-routes.test.ts`, `apps/api/test/agent-routes.test.ts`, `apps/web/src/pages/nodes.tsx`, `apps/web/src/components/node-inventory-filters.tsx`, `apps/web/src/components/recording-start-panel.tsx`, `apps/web/src/lib/recording-start-helpers.test.ts`, `apps/web/src/components/node-inventory-editors.tsx`, `crates/recorder-agent/src/node_config.rs`, `crates/recorder-agent/src/main.rs`, `crates/recorder-agent/src/main/health_tick.rs` | | Generic capture and meter command arguments | `crates/recorder-agent/src/capture.rs`, `crates/recorder-agent/src/meter_command.rs`, `crates/recorder-agent/src/command_template.rs` | | PipeWire and JACK capture and meter command presets | `crates/recorder-agent/src/config.rs`, `crates/recorder-agent/src/capture.rs`, `crates/recorder-agent/src/meter_command.rs`, `crates/recorder-agent/src/channel_map.rs`, `crates/recorder-agent/src/channel_map/tests.rs`, `crates/recorder-agent/src/node_config.rs` | -| ALSA inventory parsing, configurable probe command and `/proc/asound/pcm` path, stream/hw-params metadata, sysfs serials | `crates/recorder-agent/src/inventory.rs`, `crates/recorder-agent/src/config.rs` | +| ALSA inventory parsing, configurable probe command and `/proc/asound/pcm` path, stream/hw-params metadata, sysfs serials | `crates/recorder-agent/src/inventory.rs`, `crates/recorder-agent/src/inventory/alsa.rs`, `crates/recorder-agent/src/config.rs` | | Numeric and named ALSA capture-device inventory matching | `crates/recorder-agent/src/alsa_device.rs` | -| Runtime PipeWire/JACK availability reporting | `crates/recorder-agent/src/inventory.rs` | +| Runtime PipeWire/JACK availability reporting | `crates/recorder-agent/src/inventory.rs`, `crates/recorder-agent/src/inventory/runtime.rs` | | ALSA and synthetic meter frame generation | `crates/recorder-agent/src/telemetry.rs`, `crates/recorder-agent/src/telemetry/correlation.rs` | | Channel-map render planning for generic capture inputs | `crates/recorder-agent/src/channel_map.rs` | | Linux `snd-aloop` capture, meter, render, clean/fault fixture, and full-agent job smoke tasks | `.mise.toml`, `scripts/alsa-loopback-smoke.sh`, `scripts/agent-loopback-meter-smoke.sh`, `scripts/alsa-loopback-render-smoke.sh`, `scripts/agent-loopback-fixture-smoke.sh`, `scripts/agent-loopback-job-smoke.sh` | diff --git a/docs/internal/baselines/OPERATIONS_BASELINE.md b/docs/internal/baselines/OPERATIONS_BASELINE.md index 0a8f054..a779a6a 100644 --- a/docs/internal/baselines/OPERATIONS_BASELINE.md +++ b/docs/internal/baselines/OPERATIONS_BASELINE.md @@ -16,17 +16,17 @@ Status: MVP baseline checked. | Check | Evidence | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| Recording organization, facets, bulk metadata, delete | `apps/api/test/recording-routes.test.ts` | +| Recording organization, facets, bulk metadata, delete | `apps/api/test/recording-routes.test.ts`, `apps/api/test/recording-routes-metadata.test.ts` | | Recording manifest CSV export | `apps/api/test/recording-export-routes.test.ts` | | Recording metadata notes | `apps/api/test/recording-metadata-routes.test.ts` | | Recording UI organization helpers | `apps/web/src/lib/recording-page-helpers.test.ts` | -| Settings template reads, writes, RBAC, succeeded audits | `apps/api/test/settings-routes.test.ts` | +| Settings template reads, writes, RBAC, succeeded audits | `apps/api/test/settings-routes.test.ts`, `apps/api/test/settings-routes-mutations.test.ts` | | Settings page RBAC helpers | `apps/web/src/lib/settings-page-helpers.test.ts` | | Audit filtering and CSV export | `apps/api/test/audit-routes.test.ts` | | Audit page RBAC helpers | `apps/web/src/lib/audit-page-helpers.test.ts` | | Upload destination readiness | `apps/api/test/upload-destinations.test.ts` | | Upload policy templates | `apps/api/test/upload-policies.test.ts` | -| Upload runner and queue operations | `apps/api/test/upload-runner.test.ts` and `apps/api/test/recording-upload-queue-routes.test.ts` | +| Upload runner and queue operations | `apps/api/test/upload-runner.test.ts`, `apps/api/test/upload-runner-routes.test.ts`, and `apps/api/test/recording-upload-queue-routes.test.ts` | | Upload runner UI RBAC | `apps/web/src/lib/upload-runner-panel-helpers.test.ts` | `mise run operations:check` validates this baseline, and `mise run check` runs it. diff --git a/docs/internal/baselines/RECORDING_LIBRARY_BASELINE.md b/docs/internal/baselines/RECORDING_LIBRARY_BASELINE.md index 1650661..1ce7c8b 100644 --- a/docs/internal/baselines/RECORDING_LIBRARY_BASELINE.md +++ b/docs/internal/baselines/RECORDING_LIBRARY_BASELINE.md @@ -19,6 +19,9 @@ Status: MVP baseline checked. | Check | Evidence | | ------------------------------------------------------------------------ | ----------------------------------------------------- | | Listing, facets, filters, sorting, pagination, bulk organization, delete | `apps/api/test/recording-routes.test.ts` | +| Bulk metadata organization | `apps/api/test/recording-routes-metadata.test.ts` | +| Bulk and single recording delete | `apps/api/test/recording-routes-delete.test.ts` | +| Ad-hoc recording lifecycle | `apps/api/test/agent-routes-recording-lifecycle.test.ts` | | Route RBAC | `apps/api/test/recording-route-permissions.test.ts` | | Cache checksum and waveform preview | `apps/api/test/recording-cache.test.ts` | | CSV manifest export | `apps/api/test/recording-export-routes.test.ts` | diff --git a/docs/internal/baselines/SCHEDULER_BASELINE.md b/docs/internal/baselines/SCHEDULER_BASELINE.md index 98cbf9c..9e3a6f0 100644 --- a/docs/internal/baselines/SCHEDULER_BASELINE.md +++ b/docs/internal/baselines/SCHEDULER_BASELINE.md @@ -34,7 +34,7 @@ Status: MVP baseline checked. | Recurrence engine | `apps/api/test/schedule-engine.test.ts` | | Due-run, backend/interface selection, and schedule-owned recordings | `apps/api/test/schedule-runner.test.ts`, `apps/api/test/schedule-routes.test.ts` | | Retention assignment | `packages/db/drizzle/0021_true_midnight.sql`, `apps/api/src/schedule-store.ts`, `apps/web/src/lib/schedule-draft.ts` | -| Route RBAC and operator controls | `apps/api/test/schedule-routes.test.ts` | +| Route RBAC and operator controls | `apps/api/test/schedule-routes.test.ts`, `apps/api/test/schedule-routes-lifecycle.test.ts` | | Calendar occurrences and occurrence moves | `apps/api/test/schedule-engine.test.ts`, `apps/api/test/schedule-occurrence-routes.test.ts` | | Assignment payload and calendar occurrences | `apps/api/test/schedule-occurrence-routes.test.ts` | | UI permissions | `apps/web/src/lib/schedule-page-helpers.test.ts` | diff --git a/docs/internal/baselines/SETTINGS_TEMPLATES_BASELINE.md b/docs/internal/baselines/SETTINGS_TEMPLATES_BASELINE.md index 0d01a7b..7c79dbc 100644 --- a/docs/internal/baselines/SETTINGS_TEMPLATES_BASELINE.md +++ b/docs/internal/baselines/SETTINGS_TEMPLATES_BASELINE.md @@ -31,14 +31,14 @@ Rakkr centralizes operator-managed recording settings and reusable channel-map t | Area | Files | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Shared contracts | `packages/shared/src/index.ts` | -| Database schema | `packages/db/src/schema.ts` | +| Shared contracts | `packages/shared/src/index.ts`, `packages/shared/src/channel-maps.ts`, `packages/shared/src/recordings.ts` | +| Database schema | `packages/db/src/schema.ts`, `packages/db/src/schema/schedules.ts` | | API routes | `apps/api/src/settings-routes.ts` | | Controller settings | `apps/api/src/settings-controller-routes.ts`, `apps/api/src/controller-settings-store.ts`, `apps/api/test/settings-controller-routes.test.ts` | | Stores | `apps/api/src/settings-store.ts`, `apps/api/src/recording-profile-settings.ts`, `apps/api/src/watchdog-policy-settings.ts` | | Schedule selection | `apps/web/src/components/schedule-form-dialog.tsx` | | Rollout plans | `apps/api/src/channel-map-assignment-plans.ts` | -| API coverage | `apps/api/test/settings-routes.test.ts` | +| API coverage | `apps/api/test/settings-routes.test.ts`, `apps/api/test/settings-routes-mutations.test.ts` | | UI permissions | `apps/web/src/lib/settings-page-helpers.ts`, `apps/web/src/lib/settings-page-helpers.test.ts` | | Upload retention | `apps/api/src/upload-policies.ts`, `apps/api/src/upload-runner.ts`, `apps/api/test/upload-policies.test.ts`, `apps/api/test/upload-runner.test.ts`, `apps/web/src/components/upload-policy-panel.tsx` | | Retention templates | `apps/api/src/retention-policies.ts`, `apps/api/src/retention-policy-routes.ts`, `apps/api/test/retention-policy-routes.test.ts`, `apps/web/src/components/retention-policy-panel.tsx` | diff --git a/docs/internal/baselines/STORAGE_UPLOAD_BASELINE.md b/docs/internal/baselines/STORAGE_UPLOAD_BASELINE.md index 40b073c..a8a05bd 100644 --- a/docs/internal/baselines/STORAGE_UPLOAD_BASELINE.md +++ b/docs/internal/baselines/STORAGE_UPLOAD_BASELINE.md @@ -28,14 +28,14 @@ Status: MVP baseline checked. | Check | Evidence | | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Stub, SMB, S3, checksum, retry budget, in-flight lease recovery | `apps/api/test/upload-executor.test.ts` | -| Runner audit, run-now, cache retention, partial fan-out reconciliation | `apps/api/test/upload-runner.test.ts` | +| Runner audit, run-now, cache retention, partial fan-out reconciliation | `apps/api/test/upload-runner.test.ts`, `apps/api/test/upload-runner-routes.test.ts` | | Destination readiness and CRUD | `apps/api/test/upload-destinations.test.ts` | | Policy templates and auto-queue input | `apps/api/test/upload-policies.test.ts` | | Queue retries, duplicate completed-upload attach idempotency, and retrying-item lease expiry | `apps/api/test/upload-queue.test.ts`, `apps/api/test/agent-cache-idempotency-routes.test.ts` | | Recording queue routes and scoped filters | `apps/api/test/recording-upload-queue-routes.test.ts` | | Settings route RBAC | `apps/api/test/settings-routes.test.ts` | | Upload metrics | `apps/api/test/metrics.test.ts` | -| Agent cache auto-queue lifecycle | `apps/api/test/agent-routes.test.ts` | +| Agent cache auto-queue lifecycle | `apps/api/test/agent-routes.test.ts`, `apps/api/test/agent-routes-recording-lifecycle.test.ts` | | Upload runner UI RBAC | `apps/web/src/lib/upload-runner-panel-helpers.test.ts` | `mise run storage:check` validates this baseline, and `mise run check` runs it. diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 6d4e84b..6877adf 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,960 +1,19 @@ -import { sql } from "drizzle-orm"; -import { - bigint, - boolean, - index, - integer, - jsonb, - pgEnum, - pgTable, - primaryKey, - text, - timestamp, - uniqueIndex, - uuid, - varchar, -} from "drizzle-orm/pg-core"; - -export const nodeStatusEnum = pgEnum("node_status", [ - "provisioning", - "online", - "offline", - "degraded", - "recording", - "alerting", -]); - -export const healthSeverityEnum = pgEnum("health_severity", ["info", "warning", "critical"]); - -export const recordingStatusEnum = pgEnum("recording_status", [ - "queued", - "recording", - "completed", - "failed", - "cached", - "uploaded", - "partial", -]); -export const recordingJobStatusEnum = pgEnum("recording_job_status", [ - "queued", - "running", - "stop_requested", - "cancelled", - "completed", - "failed", -]); -export const recordingChunkStatusEnum = pgEnum("recording_chunk_status", [ - "capturing", - "cached", - "uploading", - "uploaded", - "partial", - "failed", -]); - -export const recordingSourceEnum = pgEnum("recording_source", ["ad_hoc", "schedule"]); - -export const auditOutcomeEnum = pgEnum("audit_outcome", [ - "allowed", - "denied", - "failed", - "partial", - "succeeded", -]); -export const accessPolicyEffectEnum = pgEnum("access_policy_effect", ["allow", "deny"]); -export const accessPolicySubjectTypeEnum = pgEnum("access_policy_subject_type", [ - "user", - "group", - "everyone", -]); -// Room roster subjects are always explicit (a user or a group); "everyone" is -// deliberately not a roster subject. -export const roomRosterSubjectTypeEnum = pgEnum("room_roster_subject_type", ["user", "group"]); -// Provenance of a roster entry: operator-managed, or materialized from a -// schedule/meeting assignment so calendar-derived grants reconcile independently. -export const roomRosterSourceEnum = pgEnum("room_roster_source", ["manual", "calendar"]); - -export const roles = pgTable("roles", { - description: text("description"), - id: varchar("id", { length: 64 }).primaryKey(), - name: varchar("name", { length: 120 }).notNull(), -}); - -export const permissions = pgTable("permissions", { - description: text("description"), - id: varchar("id", { length: 120 }).primaryKey(), - name: varchar("name", { length: 160 }).notNull(), -}); - -export const rolePermissions = pgTable( - "role_permissions", - { - permissionId: varchar("permission_id", { length: 120 }) - .notNull() - .references(() => permissions.id, { onDelete: "cascade" }), - roleId: varchar("role_id", { length: 64 }) - .notNull() - .references(() => roles.id, { onDelete: "cascade" }), - }, - (table) => ({ - pk: primaryKey({ columns: [table.roleId, table.permissionId] }), - }), -); - -export const users = pgTable( - "users", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - disabledAt: timestamp("disabled_at", { withTimezone: true }), - email: varchar("email", { length: 320 }).notNull().unique(), - // Stable federated-identity key (OIDC `oid`/`sub`). Null for local users. - // OIDC logins link on THIS, not email, so a mutable/unverified email claim - // can never merge onto another account (see the login-linking invariant). - externalId: varchar("external_id", { length: 320 }), - id: uuid("id").primaryKey().defaultRandom(), - name: varchar("name", { length: 160 }).notNull(), - passwordHash: text("password_hash"), - provider: varchar("provider", { length: 16 }).notNull().default("local"), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - emailIdx: index("users_email_idx").on(table.email), - // Unique per federated subject; Postgres treats NULLs as distinct, so local - // users (external_id NULL) never collide. - externalIdIdx: uniqueIndex("users_external_id_idx").on(table.externalId), - }), -); - -export const userRoles = pgTable( - "user_roles", - { - roleId: varchar("role_id", { length: 64 }) - .notNull() - .references(() => roles.id, { onDelete: "cascade" }), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - }, - (table) => ({ - pk: primaryKey({ columns: [table.userId, table.roleId] }), - roleIdx: index("user_roles_role_idx").on(table.roleId), - }), -); - -export const accessGroups = pgTable("access_groups", { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - description: text("description"), - id: varchar("id", { length: 120 }).primaryKey(), - name: varchar("name", { length: 160 }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const userAccessGroups = pgTable( - "user_access_groups", - { - groupId: varchar("group_id", { length: 120 }) - .notNull() - .references(() => accessGroups.id, { onDelete: "cascade" }), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - }, - (table) => ({ - groupIdx: index("user_access_groups_group_idx").on(table.groupId), - pk: primaryKey({ columns: [table.userId, table.groupId] }), - }), -); - -export const accessPolicies = pgTable( - "access_policies", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - createdByUserId: uuid("created_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - effect: accessPolicyEffectEnum("effect").notNull(), - id: uuid("id").primaryKey().defaultRandom(), - reason: text("reason"), - resourceId: varchar("resource_id", { length: 160 }).notNull(), - resourceType: varchar("resource_type", { length: 80 }).notNull(), - subjectId: varchar("subject_id", { length: 160 }), - subjectType: accessPolicySubjectTypeEnum("subject_type").notNull(), - }, - (table) => ({ - resourceIdx: index("access_policies_resource_idx").on(table.resourceType, table.resourceId), - subjectIdx: index("access_policies_subject_idx").on(table.subjectType, table.subjectId), - }), -); - -export const userResourceGrants = pgTable( - "user_resource_grants", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - grantedByUserId: uuid("granted_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - resourceId: varchar("resource_id", { length: 160 }).notNull(), - resourceType: varchar("resource_type", { length: 80 }).notNull(), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - }, - (table) => ({ - pk: primaryKey({ columns: [table.userId, table.resourceType, table.resourceId] }), - resourceIdx: index("user_resource_grants_resource_idx").on( - table.resourceType, - table.resourceId, - ), - userIdx: index("user_resource_grants_user_idx").on(table.userId), - }), -); - -export const authSessions = pgTable( - "auth_sessions", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - id: uuid("id").primaryKey().defaultRandom(), - ipAddress: varchar("ip_address", { length: 120 }), - lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), - revokedAt: timestamp("revoked_at", { withTimezone: true }), - tokenHash: text("token_hash").notNull().unique(), - userAgent: text("user_agent"), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - }, - (table) => ({ - expiresAtIdx: index("auth_sessions_expires_at_idx").on(table.expiresAt), - tokenHashIdx: index("auth_sessions_token_hash_idx").on(table.tokenHash), - userIdx: index("auth_sessions_user_idx").on(table.userId), - }), -); - -export const oidcLoginStates = pgTable( - "oidc_login_states", - { - codeVerifier: text("code_verifier").notNull(), - consumedAt: timestamp("consumed_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - nonce: text("nonce").notNull(), - returnTo: text("return_to"), - stateHash: text("state_hash").primaryKey(), - }, - (table) => ({ - expiresAtIdx: index("oidc_login_states_expires_at_idx").on(table.expiresAt), - }), -); - -export const nodes = pgTable( - "nodes", - { - agentVersion: varchar("agent_version", { length: 80 }).notNull(), - alias: varchar("alias", { length: 160 }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - hostname: varchar("hostname", { length: 255 }).notNull(), - id: varchar("id", { length: 160 }).primaryKey(), - lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), - location: jsonb("location") - .notNull() - .default(sql`'{}'::jsonb`), - metadata: jsonb("metadata") - .notNull() - .default(sql`'{}'::jsonb`), - network: jsonb("network") - .notNull() - .default(sql`'{}'::jsonb`), - notes: text("notes"), - // First-class room this node belongs to (source of truth for room identity; - // location jsonb is retained for building/floor + legacy display). Operator- - // set via node management; the agent never sets it. - roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { - onDelete: "set null", - }), - status: nodeStatusEnum("status").notNull().default("offline"), - tags: jsonb("tags") - .notNull() - .default(sql`'[]'::jsonb`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - aliasIdx: index("nodes_alias_idx").on(table.alias), - roomIdx: index("nodes_room_idx").on(table.roomId), - statusIdx: index("nodes_status_idx").on(table.status), - }), -); - -// First-class room. `id` is the stable RBAC scope target (opaque); (site, name) -// is unique so backfill from node locations dedupes and renames don't collide. -export const rooms = pgTable( - "rooms", - { - building: varchar("building", { length: 160 }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - description: text("description"), - floor: varchar("floor", { length: 160 }), - id: varchar("id", { length: 160 }).primaryKey(), - name: varchar("name", { length: 160 }).notNull(), - notes: text("notes"), - site: varchar("site", { length: 160 }).notNull().default("Unknown Site"), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - siteIdx: index("rooms_site_idx").on(table.site), - siteNameIdx: uniqueIndex("rooms_site_name_idx").on(table.site, table.name), - }), -); - -// Per-room access roster: one row per (room, subject, source[, sourceSchedule]). -// `capabilities` is the independently-toggled per-action set. Effective access -// for a user is the UNION of capabilities across their direct + group rows for a -// room. Uniqueness is enforced in the store (Postgres treats NULL -// sourceScheduleId as distinct, so a DB unique index would not dedupe manual rows). -export const roomRoster = pgTable( - "room_roster", - { - capabilities: jsonb("capabilities") - .notNull() - .default(sql`'[]'::jsonb`), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - grantedByUserId: uuid("granted_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - id: uuid("id").primaryKey().defaultRandom(), - roomId: varchar("room_id", { length: 160 }) - .notNull() - .references(() => rooms.id, { onDelete: "cascade" }), - source: roomRosterSourceEnum("source").notNull().default("manual"), - sourceScheduleId: varchar("source_schedule_id", { length: 160 }).references( - () => schedules.id, - { onDelete: "cascade" }, - ), - subjectId: varchar("subject_id", { length: 160 }).notNull(), - subjectType: roomRosterSubjectTypeEnum("subject_type").notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - roomIdx: index("room_roster_room_idx").on(table.roomId), - scheduleIdx: index("room_roster_schedule_idx").on(table.sourceScheduleId), - subjectIdx: index("room_roster_subject_idx").on(table.subjectType, table.subjectId), - }), -); - -// External audio matrix switcher (e.g. AVPro AC-MAX). Non-secret connection -// config is columnar; the optional control-channel password (for models whose -// control channel requires a login) is encrypted at rest in `secrets` via -// secret-box. `model`, `inputs`, and `outputs` are fixed at creation. -// `mode`: disabled (never connect) | observe (compute + audit, never send) | -// enforce (apply routing). -export const switchers = pgTable("switchers", { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - displayName: varchar("display_name", { length: 160 }).notNull(), - enabled: boolean("enabled").notNull().default(false), - host: varchar("host", { length: 255 }).notNull(), - id: varchar("id", { length: 160 }).primaryKey(), - inputs: integer("inputs").notNull(), - mode: varchar("mode", { length: 16 }).notNull().default("observe"), - model: varchar("model", { length: 48 }).notNull(), - outputs: integer("outputs").notNull(), - port: integer("port").notNull(), - secrets: jsonb("secrets") - .notNull() - .default(sql`'{}'::jsonb`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - username: varchar("username", { length: 120 }), -}); - -// Room feed wired to a switcher input jack. One room per input and one input -// per room (per switcher): the composite PK enforces one row per input, the -// room unique index enforces the reverse. -export const switcherInputMap = pgTable( - "switcher_input_map", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - input: integer("input").notNull(), - roomId: varchar("room_id", { length: 160 }) - .notNull() - .references(() => rooms.id, { onDelete: "cascade" }), - switcherId: varchar("switcher_id", { length: 160 }) - .notNull() - .references(() => switchers.id, { onDelete: "cascade" }), - }, - (table) => ({ - pk: primaryKey({ columns: [table.switcherId, table.input] }), - roomIdx: index("switcher_input_map_room_idx").on(table.roomId), - roomUnique: uniqueIndex("switcher_input_map_room_unique").on(table.switcherId, table.roomId), - }), -); - -// Listener desk wired to a switcher output jack. One user per output and one -// output per user (per switcher). -export const switcherOutputMap = pgTable( - "switcher_output_map", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - output: integer("output").notNull(), - switcherId: varchar("switcher_id", { length: 160 }) - .notNull() - .references(() => switchers.id, { onDelete: "cascade" }), - userId: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - }, - (table) => ({ - pk: primaryKey({ columns: [table.switcherId, table.output] }), - userIdx: index("switcher_output_map_user_idx").on(table.userId), - userUnique: uniqueIndex("switcher_output_map_user_unique").on(table.switcherId, table.userId), - }), -); - -export const nodeCredentials = pgTable( - "node_credentials", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - createdByUserId: uuid("created_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - id: uuid("id").primaryKey().defaultRandom(), - lastUsedAt: timestamp("last_used_at", { withTimezone: true }), - nodeId: varchar("node_id", { length: 160 }) - .notNull() - .references(() => nodes.id, { onDelete: "cascade" }), - revokedAt: timestamp("revoked_at", { withTimezone: true }), - tokenHash: text("token_hash").notNull().unique(), - tokenPrefix: varchar("token_prefix", { length: 48 }).notNull(), - }, - (table) => ({ - nodeIdx: index("node_credentials_node_idx").on(table.nodeId), - tokenPrefixIdx: index("node_credentials_token_prefix_idx").on(table.tokenPrefix), - // At most one un-revoked (active) credential per node — the DB-level guard for - // the invariant rotateCredential intends (revoke prior, then insert one). The - // revoke+insert is not atomic on its own, so two concurrent rotations could - // otherwise both insert an active credential (an un-revoked, still-valid stale - // token); this partial unique index makes the second insert fail closed. - activeNodeIdx: uniqueIndex("node_credentials_active_node_idx") - .on(table.nodeId) - .where(sql`revoked_at IS NULL`), - }), -); - -export const nodeSshCredentials = pgTable( - "node_ssh_credentials", - { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - createdByUserId: uuid("created_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - fingerprint: varchar("fingerprint", { length: 160 }).notNull(), - id: uuid("id").primaryKey().defaultRandom(), - nodeId: varchar("node_id", { length: 160 }) - .notNull() - .references(() => nodes.id, { onDelete: "cascade" }), - // SSH private keys must be replayable to authenticate the runner's SSH - // session, so they are encrypted at rest with the controller master key - // (AES-256-GCM, see node-ssh-credential-crypto), not hashed. - privateKeyEncrypted: text("private_key_encrypted").notNull(), - publicKey: text("public_key").notNull(), - revokedAt: timestamp("revoked_at", { withTimezone: true }), - rotatedAt: timestamp("rotated_at", { withTimezone: true }), - username: varchar("username", { length: 64 }).notNull().default("rakkr"), - }, - (table) => ({ - nodeIdx: index("node_ssh_credentials_node_idx").on(table.nodeId), - // At most one un-revoked (active) credential per node — the DB-level guard for - // the "one active key per node" invariant. Revoke-then-insert in persistActive - // is not atomic on its own, so two concurrent rotations could otherwise both - // insert an active credential; this partial unique index makes the second - // insert fail (rolled back with its transaction) instead. - activeNodeIdx: uniqueIndex("node_ssh_credentials_active_node_idx") - .on(table.nodeId) - .where(sql`revoked_at IS NULL`), - }), -); - -export const nodeBootstrapTokens = pgTable( - "node_bootstrap_tokens", - { - consumedAt: timestamp("consumed_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - createdByUserId: uuid("created_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - id: uuid("id").primaryKey().defaultRandom(), - nodeId: varchar("node_id", { length: 160 }) - .notNull() - .references(() => nodes.id, { onDelete: "cascade" }), - // Single-use, short-TTL bearer presented once at first boot to hand the - // node-generated SSH key to the controller. Hashed at rest like node tokens. - tokenHash: text("token_hash").notNull().unique(), - tokenPrefix: varchar("token_prefix", { length: 48 }).notNull(), - }, - (table) => ({ - nodeIdx: index("node_bootstrap_tokens_node_idx").on(table.nodeId), - }), -); - -export const audioInterfaces = pgTable( - "audio_interfaces", - { - // Set when the agent's startup inventory reconcile no longer reports this - // interface. Absent interfaces are flagged (preserving channel-map history) - // rather than hard-deleted; cleared when the device is reported again. - absentAt: timestamp("absent_at", { withTimezone: true }), - alias: varchar("alias", { length: 160 }).notNull(), - backend: varchar("backend", { length: 40 }).notNull(), - channelCount: integer("channel_count").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - hardwarePath: varchar("hardware_path", { length: 500 }), - id: uuid("id").primaryKey().defaultRandom(), - nodeId: varchar("node_id", { length: 160 }) - .notNull() - .references(() => nodes.id, { onDelete: "cascade" }), - sampleRates: jsonb("sample_rates") - .notNull() - .default(sql`'[]'::jsonb`), - serialNumber: varchar("serial_number", { length: 255 }), - systemName: varchar("system_name", { length: 255 }).notNull(), - systemRef: varchar("system_ref", { length: 255 }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - nodeIdx: index("audio_interfaces_node_idx").on(table.nodeId), - }), -); - -export const audioChannels = pgTable( - "audio_channels", - { - alias: varchar("alias", { length: 160 }).notNull(), - id: uuid("id").primaryKey().defaultRandom(), - index: integer("channel_index").notNull(), - interfaceId: uuid("interface_id") - .notNull() - .references(() => audioInterfaces.id, { onDelete: "cascade" }), - metadata: jsonb("metadata") - .notNull() - .default(sql`'{}'::jsonb`), - // Room that owns this channel. Room ownership lives at the channel level: any - // set of a node's channels can belong to a room, and each channel belongs to - // at most one room. NULL inherits the node default (nodes.room_id). Set null on - // room delete so hardware rows survive. Preserved across inventory reconcile - // (matched on interface + channel_index). - roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { - onDelete: "set null", - }), - }, - (table) => ({ - interfaceIdx: index("audio_channels_interface_idx").on(table.interfaceId), - roomIdx: index("audio_channels_room_idx").on(table.roomId), - }), -); - -export const recordingProfiles = pgTable("recording_profiles", { - bitrateKbps: integer("bitrate_kbps").notNull(), - channelMode: varchar("channel_mode", { length: 64 }).notNull(), - codec: varchar("codec", { length: 32 }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - id: varchar("id", { length: 160 }).primaryKey(), - name: varchar("name", { length: 160 }).notNull(), - settings: jsonb("settings") - .notNull() - .default(sql`'{}'::jsonb`), - silenceDetectionEnabled: boolean("silence_detection_enabled").notNull().default(false), - silenceSkipEnabled: boolean("silence_skip_enabled").notNull().default(false), - vbr: boolean("vbr").notNull().default(true), -}); - -export const controllerSettings = pgTable("controller_settings", { - controllerName: varchar("controller_name", { length: 160 }).notNull().default("Rakkr Controller"), - // Operator-chosen scheduling/ad-hoc defaults; null = no default set (forms - // fall back to the built-in profile/policy). Nullable, no FK: a referenced - // policy may be deleted, and the forms tolerate a stale id. - defaultRecordingProfileId: varchar("default_recording_profile_id", { length: 160 }), - defaultRetentionPolicyId: varchar("default_retention_policy_id", { length: 160 }), - defaultUploadPolicyId: varchar("default_upload_policy_id", { length: 160 }), - defaultWatchdogPolicyId: varchar("default_watchdog_policy_id", { length: 160 }), - id: varchar("id", { length: 64 }).primaryKey().default("controller"), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - weekStartsOn: varchar("week_starts_on", { length: 16 }).notNull().default("monday"), -}); - -export const watchdogPolicies = pgTable("watchdog_policies", { - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - id: varchar("id", { length: 160 }).primaryKey(), - name: varchar("name", { length: 160 }).notNull(), - rules: jsonb("rules") - .notNull() - .default(sql`'[]'::jsonb`), -}); - -export const channelMapTemplates = pgTable("channel_map_templates", { - channelMode: varchar("channel_mode", { length: 64 }).notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - entries: jsonb("entries") - .notNull() - .default(sql`'[]'::jsonb`), - id: varchar("id", { length: 160 }).primaryKey(), - metadata: jsonb("metadata") - .notNull() - .default(sql`'{}'::jsonb`), - name: varchar("name", { length: 160 }).notNull(), - tags: jsonb("tags") - .notNull() - .default(sql`'[]'::jsonb`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const templateAssignments = pgTable( - "template_assignments", - { - assignedAt: timestamp("assigned_at", { withTimezone: true }).notNull().defaultNow(), - assignedByUserId: uuid("assigned_by_user_id").references(() => users.id, { - onDelete: "set null", - }), - id: uuid("id").primaryKey().defaultRandom(), - metadata: jsonb("metadata") - .notNull() - .default(sql`'{}'::jsonb`), - targetId: varchar("target_id", { length: 160 }).notNull(), - targetType: varchar("target_type", { length: 40 }).notNull(), - templateId: varchar("template_id", { length: 160 }).notNull(), - templateKind: varchar("template_kind", { length: 80 }).notNull(), - }, - (table) => ({ - targetIdx: index("template_assignments_target_idx").on( - table.templateKind, - table.targetType, - table.targetId, - ), - templateIdx: index("template_assignments_template_idx").on( - table.templateKind, - table.templateId, - ), - }), -); - -export const uploadProviders = pgTable("upload_providers", { - // Non-secret typed connection config (smb/s3 fields). - config: jsonb("config") - .notNull() - .default(sql`'{}'::jsonb`), - displayName: varchar("display_name", { length: 160 }).notNull(), - enabled: boolean("enabled").notNull().default(false), - provider: varchar("provider", { length: 32 }).primaryKey(), - // Encrypted secret material (smbPassword, s3SecretAccessKey). - secrets: jsonb("secrets") - .notNull() - .default(sql`'{}'::jsonb`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -// Named SMB/S3 upload targets. Unlike upload_providers (one row per kind), many -// destinations of each kind may exist; upload policies reference one by id. -export const uploadDestinations = pgTable("upload_destinations", { - // Non-secret typed connection config (smb/s3 fields). - config: jsonb("config") - .notNull() - .default(sql`'{}'::jsonb`), - displayName: varchar("display_name", { length: 160 }).notNull(), - enabled: boolean("enabled").notNull().default(false), - id: varchar("id", { length: 160 }).primaryKey(), - kind: varchar("kind", { length: 32 }).notNull(), - // Encrypted secret material (smbPassword, s3SecretAccessKey). - secrets: jsonb("secrets") - .notNull() - .default(sql`'{}'::jsonb`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const uploadPolicies = pgTable("upload_policies", { - deleteCacheAfterUpload: boolean("delete_cache_after_upload").notNull().default(false), - destinationId: varchar("destination_id", { length: 160 }), - enabled: boolean("enabled").notNull().default(false), - id: varchar("id", { length: 160 }).primaryKey(), - maxAttempts: integer("max_attempts").notNull().default(5), - name: varchar("name", { length: 160 }).notNull(), - pathOverride: text("path_override"), - // Legacy columns superseded by destinationId/pathOverride; retained nullable for - // backfill and dropped in a later migration. - provider: varchar("provider", { length: 32 }), - target: text("target"), - trigger: varchar("trigger", { length: 40 }).notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const uploadQueueItems = pgTable( - "upload_queue_items", - { - attemptCount: integer("attempt_count").notNull().default(0), - cachePath: text("cache_path"), - checksum: varchar("checksum", { length: 160 }), - // Chunk this upload item belongs to. NULL = legacy whole-recording item. - chunkId: varchar("chunk_id", { length: 160 }), - chunkIndex: integer("chunk_index"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - destinationId: varchar("destination_id", { length: 160 }), - fileName: text("file_name").notNull(), - id: varchar("id", { length: 160 }).primaryKey(), - lastError: text("last_error"), - maxAttempts: integer("max_attempts").notNull().default(5), - nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull(), - pathOverride: text("path_override"), - provider: varchar("provider", { length: 32 }).notNull(), - recordingId: varchar("recording_id", { length: 160 }).notNull(), - status: varchar("status", { length: 32 }).notNull(), - target: text("target"), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), - uploadPolicyId: varchar("upload_policy_id", { length: 160 }), - }, - (table) => ({ - chunkIdx: index("upload_queue_items_chunk_idx").on(table.chunkId), - destinationStatusIdx: index("upload_queue_items_destination_status_idx").on( - table.destinationId, - table.status, - ), - dueIdx: index("upload_queue_items_due_idx").on(table.status, table.nextAttemptAt), - providerStatusIdx: index("upload_queue_items_provider_status_idx").on( - table.provider, - table.status, - ), - recordingIdx: index("upload_queue_items_recording_idx").on(table.recordingId), - }), -); - -export const schedules = pgTable( - "schedules", - { - // Access-group ids assigned to this schedule. Membership in an assigned - // group confers scoped RBAC (see the assignment capability bundle in the - // API) over the schedule's room. Groups are evaluated dynamically. - assignedGroupIds: jsonb("assigned_group_ids") - .notNull() - .default(sql`'[]'::jsonb`), - // User ids directly assigned to this schedule. Assignment confers scoped - // RBAC over the schedule's room without changing the user's role. - assignedUserIds: jsonb("assigned_user_ids") - .notNull() - .default(sql`'[]'::jsonb`), - captureBackend: varchar("capture_backend", { length: 32 }), - // Ordered 1-based source channel indices selected from the capture - // interface. Empty array = capture the whole interface (legacy behavior). - captureChannelSelection: jsonb("capture_channel_selection") - .notNull() - .default(sql`'[]'::jsonb`), - captureInterfaceId: varchar("capture_interface_id", { length: 160 }), - // Output mode applied to the selected channels (mono/stereo/etc). - channelMode: varchar("channel_mode", { length: 32 }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - enabled: boolean("enabled").notNull().default(true), - folderTemplate: text("folder_template").notNull(), - id: varchar("id", { length: 160 }).primaryKey(), - name: varchar("name", { length: 160 }).notNull(), - nextRunAt: timestamp("next_run_at", { withTimezone: true }), - nodeId: varchar("node_id", { length: 160 }), - recurrence: jsonb("recurrence").notNull(), - recordingProfileId: varchar("recording_profile_id", { length: 160 }), - retentionPolicyId: varchar("retention_policy_id", { length: 160 }), - // Denormalized room display name/template value; retained for one release. - // roomId is the source of truth for room identity and RBAC scope. - room: varchar("room", { length: 160 }).notNull().default("Unknown Room"), - roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { - onDelete: "restrict", - }), - tags: jsonb("tags") - .notNull() - .default(sql`'[]'::jsonb`), - timezone: varchar("timezone", { length: 80 }).notNull(), - titleTemplate: text("title_template").notNull(), - // Legacy single-policy column superseded by uploadPolicyIds; retained nullable - // for backfill and dropped in a later migration. - uploadPolicyId: varchar("upload_policy_id", { length: 160 }), - uploadPolicyIds: jsonb("upload_policy_ids") - .notNull() - .default(sql`'[]'::jsonb`), - watchdogPolicyId: varchar("watchdog_policy_id", { length: 160 }), - }, - (table) => ({ - enabledIdx: index("schedules_enabled_idx").on(table.enabled), - nodeIdx: index("schedules_node_idx").on(table.nodeId), - roomIdx: index("schedules_room_idx").on(table.roomId), - }), -); - -export const recordings = pgTable( - "recordings", - { - cachePath: text("cache_path"), - checksum: varchar("checksum", { length: 160 }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - durationSeconds: integer("duration_seconds").notNull().default(0), - // Voice-enhancement renditions. `cachePath` stays the default-playback file; - // `rawCachePath`/`enhancedCachePath` are set when both renditions exist so the - // player and live monitor can switch between raw and enhanced audio. - enhancedCachePath: text("enhanced_cache_path"), - folder: text("folder").notNull(), - rawCachePath: text("raw_cache_path"), - healthStatus: varchar("health_status", { length: 32 }).notNull().default("unknown"), - id: varchar("id", { length: 160 }).primaryKey(), - metadata: jsonb("metadata") - .notNull() - .default(sql`'{}'::jsonb`), - name: text("name").notNull(), - nodeId: varchar("node_id", { length: 160 }), - recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(), - // Room that owns this recording, captured at create time from the room of the - // selected channels. Persisted (not derived) so a later channel reassignment - // never retroactively moves a completed recording or its RBAC visibility. - roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { - onDelete: "set null", - }), - scheduleId: varchar("schedule_id", { length: 160 }), - source: recordingSourceEnum("source").notNull(), - status: recordingStatusEnum("status").notNull(), - tags: jsonb("tags") - .notNull() - .default(sql`'[]'::jsonb`), - }, - (table) => ({ - recordedAtIdx: index("recordings_recorded_at_idx").on(table.recordedAt), - roomIdx: index("recordings_room_idx").on(table.roomId), - scheduleIdx: index("recordings_schedule_idx").on(table.scheduleId), - statusIdx: index("recordings_status_idx").on(table.status), - }), -); - -export const recordingJobs = pgTable( - "recording_jobs", - { - claimedBy: varchar("claimed_by", { length: 160 }), - command: jsonb("command").notNull(), - completedAt: timestamp("completed_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull(), - failureReason: text("failure_reason"), - id: varchar("id", { length: 120 }).primaryKey(), - lastHeartbeatAt: timestamp("last_heartbeat_at", { withTimezone: true }), - leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), - nodeId: varchar("node_id", { length: 160 }).notNull(), - recordingId: varchar("recording_id", { length: 160 }).notNull(), - startedAt: timestamp("started_at", { withTimezone: true }), - status: recordingJobStatusEnum("status").notNull(), - stopRequestedAt: timestamp("stop_requested_at", { withTimezone: true }), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - leaseIdx: index("recording_jobs_lease_idx").on(table.status, table.leaseExpiresAt), - nodeStatusIdx: index("recording_jobs_node_status_idx").on(table.nodeId, table.status), - recordingIdx: index("recording_jobs_recording_idx").on(table.recordingId), - }), -); - -// One time-based segment of a recording. Many chunks belong to one recording + -// one job (`recordingId`/`jobId`); `index` is 1-based and `total` is filled in -// only once capture stops. Each chunk renders raw + enhanced and uploads as it -// closes, then fans out to its own per-destination upload_queue_items. -export const recordingChunks = pgTable( - "recording_chunks", - { - cachedAt: timestamp("cached_at", { withTimezone: true }), - cachePath: text("cache_path"), - checksum: varchar("checksum", { length: 160 }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - durationSeconds: integer("duration_seconds").notNull().default(0), - enhancedCachePath: text("enhanced_cache_path"), - id: varchar("id", { length: 160 }).primaryKey(), - index: integer("index").notNull(), - jobId: varchar("job_id", { length: 160 }).notNull(), - offsetSeconds: integer("offset_seconds").notNull().default(0), - rawCachePath: text("raw_cache_path"), - recordingId: varchar("recording_id", { length: 160 }).notNull(), - // A single chunk of multichannel/uncompressed audio (e.g. 32-ch X32 WAV) can - // exceed the 2 GiB signed-int32 ceiling, which threw "integer out of range" on - // upsert and failed the chunk. bigint (JS number, exact to 2^53 = 8 PiB) holds it. - sizeBytes: bigint("size_bytes", { mode: "number" }), - status: recordingChunkStatusEnum("status").notNull(), - total: integer("total"), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - jobIdx: index("recording_chunks_job_idx").on(table.jobId), - recordingIdx: index("recording_chunks_recording_idx").on(table.recordingId), - recordingIndexUnique: uniqueIndex("recording_chunks_recording_index_unique").on( - table.recordingId, - table.index, - ), - }), -); - -export const healthEvents = pgTable( - "health_events", - { - acknowledgedAt: timestamp("acknowledged_at", { withTimezone: true }), - acknowledgedBy: varchar("acknowledged_by", { length: 160 }), - details: jsonb("details") - .notNull() - .default(sql`'{}'::jsonb`), - id: uuid("id").primaryKey().defaultRandom(), - nodeId: varchar("node_id", { length: 160 }), - openedAt: timestamp("opened_at", { withTimezone: true }).notNull(), - recordingId: varchar("recording_id", { length: 160 }), - resolvedAt: timestamp("resolved_at", { withTimezone: true }), - resolvedBy: varchar("resolved_by", { length: 160 }), - scheduleId: varchar("schedule_id", { length: 160 }), - severity: healthSeverityEnum("severity").notNull(), - status: varchar("status", { length: 32 }).notNull().default("open"), - suppressedAt: timestamp("suppressed_at", { withTimezone: true }), - suppressedBy: varchar("suppressed_by", { length: 160 }), - suppressedUntil: timestamp("suppressed_until", { withTimezone: true }), - type: varchar("type", { length: 160 }).notNull(), - }, - (table) => ({ - nodeIdx: index("health_events_node_idx").on(table.nodeId), - openedAtIdx: index("health_events_opened_at_idx").on(table.openedAt), - severityIdx: index("health_events_severity_idx").on(table.severity), - statusIdx: index("health_events_status_idx").on(table.status), - }), -); - -export const auditEvents = pgTable( - "audit_events", - { - action: varchar("action", { length: 160 }).notNull(), - actorContext: jsonb("actor_context") - .notNull() - .default(sql`'{}'::jsonb`), - actorDisplayName: varchar("actor_display_name", { length: 160 }), - actorId: varchar("actor_id", { length: 160 }), - actorRoles: jsonb("actor_roles") - .notNull() - .default(sql`'[]'::jsonb`), - actorType: varchar("actor_type", { length: 40 }).notNull().default("user"), - actorUserId: uuid("actor_user_id").references(() => users.id, { - onDelete: "set null", - }), - after: jsonb("after"), - before: jsonb("before"), - correlationIds: jsonb("correlation_ids") - .notNull() - .default(sql`'{}'::jsonb`), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - details: jsonb("details") - .notNull() - .default(sql`'{}'::jsonb`), - id: uuid("id").primaryKey().defaultRandom(), - outcome: auditOutcomeEnum("outcome").notNull(), - permissionId: varchar("permission_id", { length: 120 }), - reason: text("reason"), - targetId: varchar("target_id", { length: 160 }), - targetName: varchar("target_name", { length: 160 }), - targetType: varchar("target_type", { length: 160 }), - }, - (table) => ({ - actionIdx: index("audit_events_action_idx").on(table.action), - actorIdx: index("audit_events_actor_idx").on(table.actorId), - createdAtIdx: index("audit_events_created_at_idx").on(table.createdAt), - outcomeIdx: index("audit_events_outcome_idx").on(table.outcome), - permissionIdx: index("audit_events_permission_idx").on(table.permissionId), - targetIdx: index("audit_events_target_idx").on(table.targetType, table.targetId), - }), -); +// Drizzle schema surface. Tables live in per-subsystem modules under ./schema; +// this file re-exports them so drizzle-kit (configured with schema: +// ./src/schema.ts) and every consumer see the full table set from one entry. +export * from "./schema/enums.js"; +export * from "./schema/auth.js"; +export * from "./schema/access.js"; +export * from "./schema/rooms.js"; +export * from "./schema/nodes.js"; +export * from "./schema/node-credentials.js"; +export * from "./schema/audio.js"; +export * from "./schema/switchers.js"; +export * from "./schema/channel-maps.js"; +export * from "./schema/settings.js"; +export * from "./schema/schedules.js"; +export * from "./schema/recordings.js"; +export * from "./schema/uploads.js"; +export * from "./schema/room-roster.js"; +export * from "./schema/health.js"; +export * from "./schema/audit.js"; diff --git a/packages/db/src/schema/access.ts b/packages/db/src/schema/access.ts new file mode 100644 index 0000000..f9ff6f6 --- /dev/null +++ b/packages/db/src/schema/access.ts @@ -0,0 +1,72 @@ +import { index, pgTable, primaryKey, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { accessPolicyEffectEnum, accessPolicySubjectTypeEnum } from "./enums.js"; +import { users } from "./auth.js"; + +export const accessGroups = pgTable("access_groups", { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + description: text("description"), + id: varchar("id", { length: 120 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const userAccessGroups = pgTable( + "user_access_groups", + { + groupId: varchar("group_id", { length: 120 }) + .notNull() + .references(() => accessGroups.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => ({ + groupIdx: index("user_access_groups_group_idx").on(table.groupId), + pk: primaryKey({ columns: [table.userId, table.groupId] }), + }), +); + +export const accessPolicies = pgTable( + "access_policies", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + createdByUserId: uuid("created_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + effect: accessPolicyEffectEnum("effect").notNull(), + id: uuid("id").primaryKey().defaultRandom(), + reason: text("reason"), + resourceId: varchar("resource_id", { length: 160 }).notNull(), + resourceType: varchar("resource_type", { length: 80 }).notNull(), + subjectId: varchar("subject_id", { length: 160 }), + subjectType: accessPolicySubjectTypeEnum("subject_type").notNull(), + }, + (table) => ({ + resourceIdx: index("access_policies_resource_idx").on(table.resourceType, table.resourceId), + subjectIdx: index("access_policies_subject_idx").on(table.subjectType, table.subjectId), + }), +); + +export const userResourceGrants = pgTable( + "user_resource_grants", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + grantedByUserId: uuid("granted_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + resourceId: varchar("resource_id", { length: 160 }).notNull(), + resourceType: varchar("resource_type", { length: 80 }).notNull(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.userId, table.resourceType, table.resourceId] }), + resourceIdx: index("user_resource_grants_resource_idx").on( + table.resourceType, + table.resourceId, + ), + userIdx: index("user_resource_grants_user_idx").on(table.userId), + }), +); diff --git a/packages/db/src/schema/audio.ts b/packages/db/src/schema/audio.ts new file mode 100644 index 0000000..00ae7da --- /dev/null +++ b/packages/db/src/schema/audio.ts @@ -0,0 +1,61 @@ +import { sql } from "drizzle-orm"; +import { index, integer, jsonb, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { nodes } from "./nodes.js"; +import { rooms } from "./rooms.js"; + +export const audioInterfaces = pgTable( + "audio_interfaces", + { + // Set when the agent's startup inventory reconcile no longer reports this + // interface. Absent interfaces are flagged (preserving channel-map history) + // rather than hard-deleted; cleared when the device is reported again. + absentAt: timestamp("absent_at", { withTimezone: true }), + alias: varchar("alias", { length: 160 }).notNull(), + backend: varchar("backend", { length: 40 }).notNull(), + channelCount: integer("channel_count").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + hardwarePath: varchar("hardware_path", { length: 500 }), + id: uuid("id").primaryKey().defaultRandom(), + nodeId: varchar("node_id", { length: 160 }) + .notNull() + .references(() => nodes.id, { onDelete: "cascade" }), + sampleRates: jsonb("sample_rates") + .notNull() + .default(sql`'[]'::jsonb`), + serialNumber: varchar("serial_number", { length: 255 }), + systemName: varchar("system_name", { length: 255 }).notNull(), + systemRef: varchar("system_ref", { length: 255 }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + nodeIdx: index("audio_interfaces_node_idx").on(table.nodeId), + }), +); + +export const audioChannels = pgTable( + "audio_channels", + { + alias: varchar("alias", { length: 160 }).notNull(), + id: uuid("id").primaryKey().defaultRandom(), + index: integer("channel_index").notNull(), + interfaceId: uuid("interface_id") + .notNull() + .references(() => audioInterfaces.id, { onDelete: "cascade" }), + metadata: jsonb("metadata") + .notNull() + .default(sql`'{}'::jsonb`), + // Room that owns this channel. Room ownership lives at the channel level: any + // set of a node's channels can belong to a room, and each channel belongs to + // at most one room. NULL inherits the node default (nodes.room_id). Set null on + // room delete so hardware rows survive. Preserved across inventory reconcile + // (matched on interface + channel_index). + roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { + onDelete: "set null", + }), + }, + (table) => ({ + interfaceIdx: index("audio_channels_interface_idx").on(table.interfaceId), + roomIdx: index("audio_channels_room_idx").on(table.roomId), + }), +); diff --git a/packages/db/src/schema/audit.ts b/packages/db/src/schema/audit.ts new file mode 100644 index 0000000..cb12187 --- /dev/null +++ b/packages/db/src/schema/audit.ts @@ -0,0 +1,48 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, text, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { users } from "./auth.js"; +import { auditOutcomeEnum } from "./enums.js"; + +export const auditEvents = pgTable( + "audit_events", + { + action: varchar("action", { length: 160 }).notNull(), + actorContext: jsonb("actor_context") + .notNull() + .default(sql`'{}'::jsonb`), + actorDisplayName: varchar("actor_display_name", { length: 160 }), + actorId: varchar("actor_id", { length: 160 }), + actorRoles: jsonb("actor_roles") + .notNull() + .default(sql`'[]'::jsonb`), + actorType: varchar("actor_type", { length: 40 }).notNull().default("user"), + actorUserId: uuid("actor_user_id").references(() => users.id, { + onDelete: "set null", + }), + after: jsonb("after"), + before: jsonb("before"), + correlationIds: jsonb("correlation_ids") + .notNull() + .default(sql`'{}'::jsonb`), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + details: jsonb("details") + .notNull() + .default(sql`'{}'::jsonb`), + id: uuid("id").primaryKey().defaultRandom(), + outcome: auditOutcomeEnum("outcome").notNull(), + permissionId: varchar("permission_id", { length: 120 }), + reason: text("reason"), + targetId: varchar("target_id", { length: 160 }), + targetName: varchar("target_name", { length: 160 }), + targetType: varchar("target_type", { length: 160 }), + }, + (table) => ({ + actionIdx: index("audit_events_action_idx").on(table.action), + actorIdx: index("audit_events_actor_idx").on(table.actorId), + createdAtIdx: index("audit_events_created_at_idx").on(table.createdAt), + outcomeIdx: index("audit_events_outcome_idx").on(table.outcome), + permissionIdx: index("audit_events_permission_idx").on(table.permissionId), + targetIdx: index("audit_events_target_idx").on(table.targetType, table.targetId), + }), +); diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts new file mode 100644 index 0000000..dd4c818 --- /dev/null +++ b/packages/db/src/schema/auth.ts @@ -0,0 +1,115 @@ +import { + index, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +export const roles = pgTable("roles", { + description: text("description"), + id: varchar("id", { length: 64 }).primaryKey(), + name: varchar("name", { length: 120 }).notNull(), +}); + +export const permissions = pgTable("permissions", { + description: text("description"), + id: varchar("id", { length: 120 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), +}); + +export const rolePermissions = pgTable( + "role_permissions", + { + permissionId: varchar("permission_id", { length: 120 }) + .notNull() + .references(() => permissions.id, { onDelete: "cascade" }), + roleId: varchar("role_id", { length: 64 }) + .notNull() + .references(() => roles.id, { onDelete: "cascade" }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.roleId, table.permissionId] }), + }), +); + +export const users = pgTable( + "users", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + disabledAt: timestamp("disabled_at", { withTimezone: true }), + email: varchar("email", { length: 320 }).notNull().unique(), + // Stable federated-identity key (OIDC `oid`/`sub`). Null for local users. + // OIDC logins link on THIS, not email, so a mutable/unverified email claim + // can never merge onto another account (see the login-linking invariant). + externalId: varchar("external_id", { length: 320 }), + id: uuid("id").primaryKey().defaultRandom(), + name: varchar("name", { length: 160 }).notNull(), + passwordHash: text("password_hash"), + provider: varchar("provider", { length: 16 }).notNull().default("local"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + emailIdx: index("users_email_idx").on(table.email), + // Unique per federated subject; Postgres treats NULLs as distinct, so local + // users (external_id NULL) never collide. + externalIdIdx: uniqueIndex("users_external_id_idx").on(table.externalId), + }), +); + +export const userRoles = pgTable( + "user_roles", + { + roleId: varchar("role_id", { length: 64 }) + .notNull() + .references(() => roles.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.userId, table.roleId] }), + roleIdx: index("user_roles_role_idx").on(table.roleId), + }), +); + +export const authSessions = pgTable( + "auth_sessions", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + id: uuid("id").primaryKey().defaultRandom(), + ipAddress: varchar("ip_address", { length: 120 }), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + tokenHash: text("token_hash").notNull().unique(), + userAgent: text("user_agent"), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => ({ + expiresAtIdx: index("auth_sessions_expires_at_idx").on(table.expiresAt), + tokenHashIdx: index("auth_sessions_token_hash_idx").on(table.tokenHash), + userIdx: index("auth_sessions_user_idx").on(table.userId), + }), +); + +export const oidcLoginStates = pgTable( + "oidc_login_states", + { + codeVerifier: text("code_verifier").notNull(), + consumedAt: timestamp("consumed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + nonce: text("nonce").notNull(), + returnTo: text("return_to"), + stateHash: text("state_hash").primaryKey(), + }, + (table) => ({ + expiresAtIdx: index("oidc_login_states_expires_at_idx").on(table.expiresAt), + }), +); diff --git a/packages/db/src/schema/channel-maps.ts b/packages/db/src/schema/channel-maps.ts new file mode 100644 index 0000000..e1b3b7f --- /dev/null +++ b/packages/db/src/schema/channel-maps.ts @@ -0,0 +1,50 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { users } from "./auth.js"; + +export const channelMapTemplates = pgTable("channel_map_templates", { + channelMode: varchar("channel_mode", { length: 64 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + entries: jsonb("entries") + .notNull() + .default(sql`'[]'::jsonb`), + id: varchar("id", { length: 160 }).primaryKey(), + metadata: jsonb("metadata") + .notNull() + .default(sql`'{}'::jsonb`), + name: varchar("name", { length: 160 }).notNull(), + tags: jsonb("tags") + .notNull() + .default(sql`'[]'::jsonb`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const templateAssignments = pgTable( + "template_assignments", + { + assignedAt: timestamp("assigned_at", { withTimezone: true }).notNull().defaultNow(), + assignedByUserId: uuid("assigned_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + id: uuid("id").primaryKey().defaultRandom(), + metadata: jsonb("metadata") + .notNull() + .default(sql`'{}'::jsonb`), + targetId: varchar("target_id", { length: 160 }).notNull(), + targetType: varchar("target_type", { length: 40 }).notNull(), + templateId: varchar("template_id", { length: 160 }).notNull(), + templateKind: varchar("template_kind", { length: 80 }).notNull(), + }, + (table) => ({ + targetIdx: index("template_assignments_target_idx").on( + table.templateKind, + table.targetType, + table.targetId, + ), + templateIdx: index("template_assignments_template_idx").on( + table.templateKind, + table.templateId, + ), + }), +); diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts new file mode 100644 index 0000000..5439749 --- /dev/null +++ b/packages/db/src/schema/enums.ts @@ -0,0 +1,60 @@ +import { pgEnum } from "drizzle-orm/pg-core"; + +export const nodeStatusEnum = pgEnum("node_status", [ + "provisioning", + "online", + "offline", + "degraded", + "recording", + "alerting", +]); + +export const healthSeverityEnum = pgEnum("health_severity", ["info", "warning", "critical"]); + +export const recordingStatusEnum = pgEnum("recording_status", [ + "queued", + "recording", + "completed", + "failed", + "cached", + "uploaded", + "partial", +]); +export const recordingJobStatusEnum = pgEnum("recording_job_status", [ + "queued", + "running", + "stop_requested", + "cancelled", + "completed", + "failed", +]); +export const recordingChunkStatusEnum = pgEnum("recording_chunk_status", [ + "capturing", + "cached", + "uploading", + "uploaded", + "partial", + "failed", +]); + +export const recordingSourceEnum = pgEnum("recording_source", ["ad_hoc", "schedule"]); + +export const auditOutcomeEnum = pgEnum("audit_outcome", [ + "allowed", + "denied", + "failed", + "partial", + "succeeded", +]); +export const accessPolicyEffectEnum = pgEnum("access_policy_effect", ["allow", "deny"]); +export const accessPolicySubjectTypeEnum = pgEnum("access_policy_subject_type", [ + "user", + "group", + "everyone", +]); +// Room roster subjects are always explicit (a user or a group); "everyone" is +// deliberately not a roster subject. +export const roomRosterSubjectTypeEnum = pgEnum("room_roster_subject_type", ["user", "group"]); +// Provenance of a roster entry: operator-managed, or materialized from a +// schedule/meeting assignment so calendar-derived grants reconcile independently. +export const roomRosterSourceEnum = pgEnum("room_roster_source", ["manual", "calendar"]); diff --git a/packages/db/src/schema/health.ts b/packages/db/src/schema/health.ts new file mode 100644 index 0000000..86342f0 --- /dev/null +++ b/packages/db/src/schema/health.ts @@ -0,0 +1,34 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { healthSeverityEnum } from "./enums.js"; + +export const healthEvents = pgTable( + "health_events", + { + acknowledgedAt: timestamp("acknowledged_at", { withTimezone: true }), + acknowledgedBy: varchar("acknowledged_by", { length: 160 }), + details: jsonb("details") + .notNull() + .default(sql`'{}'::jsonb`), + id: uuid("id").primaryKey().defaultRandom(), + nodeId: varchar("node_id", { length: 160 }), + openedAt: timestamp("opened_at", { withTimezone: true }).notNull(), + recordingId: varchar("recording_id", { length: 160 }), + resolvedAt: timestamp("resolved_at", { withTimezone: true }), + resolvedBy: varchar("resolved_by", { length: 160 }), + scheduleId: varchar("schedule_id", { length: 160 }), + severity: healthSeverityEnum("severity").notNull(), + status: varchar("status", { length: 32 }).notNull().default("open"), + suppressedAt: timestamp("suppressed_at", { withTimezone: true }), + suppressedBy: varchar("suppressed_by", { length: 160 }), + suppressedUntil: timestamp("suppressed_until", { withTimezone: true }), + type: varchar("type", { length: 160 }).notNull(), + }, + (table) => ({ + nodeIdx: index("health_events_node_idx").on(table.nodeId), + openedAtIdx: index("health_events_opened_at_idx").on(table.openedAt), + severityIdx: index("health_events_severity_idx").on(table.severity), + statusIdx: index("health_events_status_idx").on(table.status), + }), +); diff --git a/packages/db/src/schema/node-credentials.ts b/packages/db/src/schema/node-credentials.ts new file mode 100644 index 0000000..66ef6bc --- /dev/null +++ b/packages/db/src/schema/node-credentials.ts @@ -0,0 +1,92 @@ +import { sql } from "drizzle-orm"; +import { index, pgTable, text, timestamp, uniqueIndex, uuid, varchar } from "drizzle-orm/pg-core"; + +import { users } from "./auth.js"; +import { nodes } from "./nodes.js"; + +export const nodeCredentials = pgTable( + "node_credentials", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + createdByUserId: uuid("created_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + id: uuid("id").primaryKey().defaultRandom(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + nodeId: varchar("node_id", { length: 160 }) + .notNull() + .references(() => nodes.id, { onDelete: "cascade" }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + tokenHash: text("token_hash").notNull().unique(), + tokenPrefix: varchar("token_prefix", { length: 48 }).notNull(), + }, + (table) => ({ + nodeIdx: index("node_credentials_node_idx").on(table.nodeId), + tokenPrefixIdx: index("node_credentials_token_prefix_idx").on(table.tokenPrefix), + // At most one un-revoked (active) credential per node — the DB-level guard for + // the invariant rotateCredential intends (revoke prior, then insert one). The + // revoke+insert is not atomic on its own, so two concurrent rotations could + // otherwise both insert an active credential (an un-revoked, still-valid stale + // token); this partial unique index makes the second insert fail closed. + activeNodeIdx: uniqueIndex("node_credentials_active_node_idx") + .on(table.nodeId) + .where(sql`revoked_at IS NULL`), + }), +); + +export const nodeSshCredentials = pgTable( + "node_ssh_credentials", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + createdByUserId: uuid("created_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + fingerprint: varchar("fingerprint", { length: 160 }).notNull(), + id: uuid("id").primaryKey().defaultRandom(), + nodeId: varchar("node_id", { length: 160 }) + .notNull() + .references(() => nodes.id, { onDelete: "cascade" }), + // SSH private keys must be replayable to authenticate the runner's SSH + // session, so they are encrypted at rest with the controller master key + // (AES-256-GCM, see node-ssh-credential-crypto), not hashed. + privateKeyEncrypted: text("private_key_encrypted").notNull(), + publicKey: text("public_key").notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + rotatedAt: timestamp("rotated_at", { withTimezone: true }), + username: varchar("username", { length: 64 }).notNull().default("rakkr"), + }, + (table) => ({ + nodeIdx: index("node_ssh_credentials_node_idx").on(table.nodeId), + // At most one un-revoked (active) credential per node — the DB-level guard for + // the "one active key per node" invariant. Revoke-then-insert in persistActive + // is not atomic on its own, so two concurrent rotations could otherwise both + // insert an active credential; this partial unique index makes the second + // insert fail (rolled back with its transaction) instead. + activeNodeIdx: uniqueIndex("node_ssh_credentials_active_node_idx") + .on(table.nodeId) + .where(sql`revoked_at IS NULL`), + }), +); + +export const nodeBootstrapTokens = pgTable( + "node_bootstrap_tokens", + { + consumedAt: timestamp("consumed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + createdByUserId: uuid("created_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + id: uuid("id").primaryKey().defaultRandom(), + nodeId: varchar("node_id", { length: 160 }) + .notNull() + .references(() => nodes.id, { onDelete: "cascade" }), + // Single-use, short-TTL bearer presented once at first boot to hand the + // node-generated SSH key to the controller. Hashed at rest like node tokens. + tokenHash: text("token_hash").notNull().unique(), + tokenPrefix: varchar("token_prefix", { length: 48 }).notNull(), + }, + (table) => ({ + nodeIdx: index("node_bootstrap_tokens_node_idx").on(table.nodeId), + }), +); diff --git a/packages/db/src/schema/nodes.ts b/packages/db/src/schema/nodes.ts new file mode 100644 index 0000000..3fec288 --- /dev/null +++ b/packages/db/src/schema/nodes.ts @@ -0,0 +1,43 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core"; + +import { nodeStatusEnum } from "./enums.js"; +import { rooms } from "./rooms.js"; + +export const nodes = pgTable( + "nodes", + { + agentVersion: varchar("agent_version", { length: 80 }).notNull(), + alias: varchar("alias", { length: 160 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + hostname: varchar("hostname", { length: 255 }).notNull(), + id: varchar("id", { length: 160 }).primaryKey(), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), + location: jsonb("location") + .notNull() + .default(sql`'{}'::jsonb`), + metadata: jsonb("metadata") + .notNull() + .default(sql`'{}'::jsonb`), + network: jsonb("network") + .notNull() + .default(sql`'{}'::jsonb`), + notes: text("notes"), + // First-class room this node belongs to (source of truth for room identity; + // location jsonb is retained for building/floor + legacy display). Operator- + // set via node management; the agent never sets it. + roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { + onDelete: "set null", + }), + status: nodeStatusEnum("status").notNull().default("offline"), + tags: jsonb("tags") + .notNull() + .default(sql`'[]'::jsonb`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + aliasIdx: index("nodes_alias_idx").on(table.alias), + roomIdx: index("nodes_room_idx").on(table.roomId), + statusIdx: index("nodes_status_idx").on(table.status), + }), +); diff --git a/packages/db/src/schema/recordings.ts b/packages/db/src/schema/recordings.ts new file mode 100644 index 0000000..7c02c1f --- /dev/null +++ b/packages/db/src/schema/recordings.ts @@ -0,0 +1,124 @@ +import { sql } from "drizzle-orm"; +import { + bigint, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uniqueIndex, + varchar, +} from "drizzle-orm/pg-core"; + +import { + recordingChunkStatusEnum, + recordingJobStatusEnum, + recordingSourceEnum, + recordingStatusEnum, +} from "./enums.js"; +import { rooms } from "./rooms.js"; + +export const recordings = pgTable( + "recordings", + { + cachePath: text("cache_path"), + checksum: varchar("checksum", { length: 160 }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + durationSeconds: integer("duration_seconds").notNull().default(0), + // Voice-enhancement renditions. `cachePath` stays the default-playback file; + // `rawCachePath`/`enhancedCachePath` are set when both renditions exist so the + // player and live monitor can switch between raw and enhanced audio. + enhancedCachePath: text("enhanced_cache_path"), + folder: text("folder").notNull(), + rawCachePath: text("raw_cache_path"), + healthStatus: varchar("health_status", { length: 32 }).notNull().default("unknown"), + id: varchar("id", { length: 160 }).primaryKey(), + metadata: jsonb("metadata") + .notNull() + .default(sql`'{}'::jsonb`), + name: text("name").notNull(), + nodeId: varchar("node_id", { length: 160 }), + recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(), + // Room that owns this recording, captured at create time from the room of the + // selected channels. Persisted (not derived) so a later channel reassignment + // never retroactively moves a completed recording or its RBAC visibility. + roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { + onDelete: "set null", + }), + scheduleId: varchar("schedule_id", { length: 160 }), + source: recordingSourceEnum("source").notNull(), + status: recordingStatusEnum("status").notNull(), + tags: jsonb("tags") + .notNull() + .default(sql`'[]'::jsonb`), + }, + (table) => ({ + recordedAtIdx: index("recordings_recorded_at_idx").on(table.recordedAt), + roomIdx: index("recordings_room_idx").on(table.roomId), + scheduleIdx: index("recordings_schedule_idx").on(table.scheduleId), + statusIdx: index("recordings_status_idx").on(table.status), + }), +); + +export const recordingJobs = pgTable( + "recording_jobs", + { + claimedBy: varchar("claimed_by", { length: 160 }), + command: jsonb("command").notNull(), + completedAt: timestamp("completed_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + failureReason: text("failure_reason"), + id: varchar("id", { length: 120 }).primaryKey(), + lastHeartbeatAt: timestamp("last_heartbeat_at", { withTimezone: true }), + leaseExpiresAt: timestamp("lease_expires_at", { withTimezone: true }), + nodeId: varchar("node_id", { length: 160 }).notNull(), + recordingId: varchar("recording_id", { length: 160 }).notNull(), + startedAt: timestamp("started_at", { withTimezone: true }), + status: recordingJobStatusEnum("status").notNull(), + stopRequestedAt: timestamp("stop_requested_at", { withTimezone: true }), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + leaseIdx: index("recording_jobs_lease_idx").on(table.status, table.leaseExpiresAt), + nodeStatusIdx: index("recording_jobs_node_status_idx").on(table.nodeId, table.status), + recordingIdx: index("recording_jobs_recording_idx").on(table.recordingId), + }), +); + +// One time-based segment of a recording. Many chunks belong to one recording + +// one job (`recordingId`/`jobId`); `index` is 1-based and `total` is filled in +// only once capture stops. Each chunk renders raw + enhanced and uploads as it +// closes, then fans out to its own per-destination upload_queue_items. +export const recordingChunks = pgTable( + "recording_chunks", + { + cachedAt: timestamp("cached_at", { withTimezone: true }), + cachePath: text("cache_path"), + checksum: varchar("checksum", { length: 160 }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + durationSeconds: integer("duration_seconds").notNull().default(0), + enhancedCachePath: text("enhanced_cache_path"), + id: varchar("id", { length: 160 }).primaryKey(), + index: integer("index").notNull(), + jobId: varchar("job_id", { length: 160 }).notNull(), + offsetSeconds: integer("offset_seconds").notNull().default(0), + rawCachePath: text("raw_cache_path"), + recordingId: varchar("recording_id", { length: 160 }).notNull(), + // A single chunk of multichannel/uncompressed audio (e.g. 32-ch X32 WAV) can + // exceed the 2 GiB signed-int32 ceiling, which threw "integer out of range" on + // upsert and failed the chunk. bigint (JS number, exact to 2^53 = 8 PiB) holds it. + sizeBytes: bigint("size_bytes", { mode: "number" }), + status: recordingChunkStatusEnum("status").notNull(), + total: integer("total"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + jobIdx: index("recording_chunks_job_idx").on(table.jobId), + recordingIdx: index("recording_chunks_recording_idx").on(table.recordingId), + recordingIndexUnique: uniqueIndex("recording_chunks_recording_index_unique").on( + table.recordingId, + table.index, + ), + }), +); diff --git a/packages/db/src/schema/room-roster.ts b/packages/db/src/schema/room-roster.ts new file mode 100644 index 0000000..844a10b --- /dev/null +++ b/packages/db/src/schema/room-roster.ts @@ -0,0 +1,42 @@ +import { sql } from "drizzle-orm"; +import { index, jsonb, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { users } from "./auth.js"; +import { roomRosterSourceEnum, roomRosterSubjectTypeEnum } from "./enums.js"; +import { rooms } from "./rooms.js"; +import { schedules } from "./schedules.js"; + +// Per-room access roster: one row per (room, subject, source[, sourceSchedule]). +// `capabilities` is the independently-toggled per-action set. Effective access +// for a user is the UNION of capabilities across their direct + group rows for a +// room. Uniqueness is enforced in the store (Postgres treats NULL +// sourceScheduleId as distinct, so a DB unique index would not dedupe manual rows). +export const roomRoster = pgTable( + "room_roster", + { + capabilities: jsonb("capabilities") + .notNull() + .default(sql`'[]'::jsonb`), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + grantedByUserId: uuid("granted_by_user_id").references(() => users.id, { + onDelete: "set null", + }), + id: uuid("id").primaryKey().defaultRandom(), + roomId: varchar("room_id", { length: 160 }) + .notNull() + .references(() => rooms.id, { onDelete: "cascade" }), + source: roomRosterSourceEnum("source").notNull().default("manual"), + sourceScheduleId: varchar("source_schedule_id", { length: 160 }).references( + () => schedules.id, + { onDelete: "cascade" }, + ), + subjectId: varchar("subject_id", { length: 160 }).notNull(), + subjectType: roomRosterSubjectTypeEnum("subject_type").notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + roomIdx: index("room_roster_room_idx").on(table.roomId), + scheduleIdx: index("room_roster_schedule_idx").on(table.sourceScheduleId), + subjectIdx: index("room_roster_subject_idx").on(table.subjectType, table.subjectId), + }), +); diff --git a/packages/db/src/schema/rooms.ts b/packages/db/src/schema/rooms.ts new file mode 100644 index 0000000..da270ae --- /dev/null +++ b/packages/db/src/schema/rooms.ts @@ -0,0 +1,22 @@ +import { index, pgTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core"; + +// First-class room. `id` is the stable RBAC scope target (opaque); (site, name) +// is unique so backfill from node locations dedupes and renames don't collide. +export const rooms = pgTable( + "rooms", + { + building: varchar("building", { length: 160 }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + description: text("description"), + floor: varchar("floor", { length: 160 }), + id: varchar("id", { length: 160 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), + notes: text("notes"), + site: varchar("site", { length: 160 }).notNull().default("Unknown Site"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + siteIdx: index("rooms_site_idx").on(table.site), + siteNameIdx: uniqueIndex("rooms_site_name_idx").on(table.site, table.name), + }), +); diff --git a/packages/db/src/schema/schedules.ts b/packages/db/src/schema/schedules.ts new file mode 100644 index 0000000..d31d28e --- /dev/null +++ b/packages/db/src/schema/schedules.ts @@ -0,0 +1,63 @@ +import { sql } from "drizzle-orm"; +import { boolean, index, jsonb, pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core"; + +import { rooms } from "./rooms.js"; + +export const schedules = pgTable( + "schedules", + { + // Access-group ids assigned to this schedule. Membership in an assigned + // group confers scoped RBAC (see the assignment capability bundle in the + // API) over the schedule's room. Groups are evaluated dynamically. + assignedGroupIds: jsonb("assigned_group_ids") + .notNull() + .default(sql`'[]'::jsonb`), + // User ids directly assigned to this schedule. Assignment confers scoped + // RBAC over the schedule's room without changing the user's role. + assignedUserIds: jsonb("assigned_user_ids") + .notNull() + .default(sql`'[]'::jsonb`), + captureBackend: varchar("capture_backend", { length: 32 }), + // Ordered 1-based source channel indices selected from the capture + // interface. Empty array = capture the whole interface (legacy behavior). + captureChannelSelection: jsonb("capture_channel_selection") + .notNull() + .default(sql`'[]'::jsonb`), + captureInterfaceId: varchar("capture_interface_id", { length: 160 }), + // Output mode applied to the selected channels (mono/stereo/etc). + channelMode: varchar("channel_mode", { length: 32 }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + enabled: boolean("enabled").notNull().default(true), + folderTemplate: text("folder_template").notNull(), + id: varchar("id", { length: 160 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), + nextRunAt: timestamp("next_run_at", { withTimezone: true }), + nodeId: varchar("node_id", { length: 160 }), + recurrence: jsonb("recurrence").notNull(), + recordingProfileId: varchar("recording_profile_id", { length: 160 }), + retentionPolicyId: varchar("retention_policy_id", { length: 160 }), + // Denormalized room display name/template value; retained for one release. + // roomId is the source of truth for room identity and RBAC scope. + room: varchar("room", { length: 160 }).notNull().default("Unknown Room"), + roomId: varchar("room_id", { length: 160 }).references(() => rooms.id, { + onDelete: "restrict", + }), + tags: jsonb("tags") + .notNull() + .default(sql`'[]'::jsonb`), + timezone: varchar("timezone", { length: 80 }).notNull(), + titleTemplate: text("title_template").notNull(), + // Legacy single-policy column superseded by uploadPolicyIds; retained nullable + // for backfill and dropped in a later migration. + uploadPolicyId: varchar("upload_policy_id", { length: 160 }), + uploadPolicyIds: jsonb("upload_policy_ids") + .notNull() + .default(sql`'[]'::jsonb`), + watchdogPolicyId: varchar("watchdog_policy_id", { length: 160 }), + }, + (table) => ({ + enabledIdx: index("schedules_enabled_idx").on(table.enabled), + nodeIdx: index("schedules_node_idx").on(table.nodeId), + roomIdx: index("schedules_room_idx").on(table.roomId), + }), +); diff --git a/packages/db/src/schema/settings.ts b/packages/db/src/schema/settings.ts new file mode 100644 index 0000000..4bc719f --- /dev/null +++ b/packages/db/src/schema/settings.ts @@ -0,0 +1,40 @@ +import { sql } from "drizzle-orm"; +import { boolean, integer, jsonb, pgTable, timestamp, varchar } from "drizzle-orm/pg-core"; + +export const recordingProfiles = pgTable("recording_profiles", { + bitrateKbps: integer("bitrate_kbps").notNull(), + channelMode: varchar("channel_mode", { length: 64 }).notNull(), + codec: varchar("codec", { length: 32 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + id: varchar("id", { length: 160 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), + settings: jsonb("settings") + .notNull() + .default(sql`'{}'::jsonb`), + silenceDetectionEnabled: boolean("silence_detection_enabled").notNull().default(false), + silenceSkipEnabled: boolean("silence_skip_enabled").notNull().default(false), + vbr: boolean("vbr").notNull().default(true), +}); + +export const controllerSettings = pgTable("controller_settings", { + controllerName: varchar("controller_name", { length: 160 }).notNull().default("Rakkr Controller"), + // Operator-chosen scheduling/ad-hoc defaults; null = no default set (forms + // fall back to the built-in profile/policy). Nullable, no FK: a referenced + // policy may be deleted, and the forms tolerate a stale id. + defaultRecordingProfileId: varchar("default_recording_profile_id", { length: 160 }), + defaultRetentionPolicyId: varchar("default_retention_policy_id", { length: 160 }), + defaultUploadPolicyId: varchar("default_upload_policy_id", { length: 160 }), + defaultWatchdogPolicyId: varchar("default_watchdog_policy_id", { length: 160 }), + id: varchar("id", { length: 64 }).primaryKey().default("controller"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + weekStartsOn: varchar("week_starts_on", { length: 16 }).notNull().default("monday"), +}); + +export const watchdogPolicies = pgTable("watchdog_policies", { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + id: varchar("id", { length: 160 }).primaryKey(), + name: varchar("name", { length: 160 }).notNull(), + rules: jsonb("rules") + .notNull() + .default(sql`'[]'::jsonb`), +}); diff --git a/packages/db/src/schema/switchers.ts b/packages/db/src/schema/switchers.ts new file mode 100644 index 0000000..4e9e8e5 --- /dev/null +++ b/packages/db/src/schema/switchers.ts @@ -0,0 +1,83 @@ +import { sql } from "drizzle-orm"; +import { + boolean, + index, + integer, + jsonb, + pgTable, + primaryKey, + timestamp, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core"; + +import { users } from "./auth.js"; +import { rooms } from "./rooms.js"; + +// External audio matrix switcher (e.g. AVPro AC-MAX). Non-secret connection +// config is columnar; the optional control-channel password (for models whose +// control channel requires a login) is encrypted at rest in `secrets` via +// secret-box. `model`, `inputs`, and `outputs` are fixed at creation. +// `mode`: disabled (never connect) | observe (compute + audit, never send) | +// enforce (apply routing). +export const switchers = pgTable("switchers", { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + displayName: varchar("display_name", { length: 160 }).notNull(), + enabled: boolean("enabled").notNull().default(false), + host: varchar("host", { length: 255 }).notNull(), + id: varchar("id", { length: 160 }).primaryKey(), + inputs: integer("inputs").notNull(), + mode: varchar("mode", { length: 16 }).notNull().default("observe"), + model: varchar("model", { length: 48 }).notNull(), + outputs: integer("outputs").notNull(), + port: integer("port").notNull(), + secrets: jsonb("secrets") + .notNull() + .default(sql`'{}'::jsonb`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + username: varchar("username", { length: 120 }), +}); + +// Room feed wired to a switcher input jack. One room per input and one input +// per room (per switcher): the composite PK enforces one row per input, the +// room unique index enforces the reverse. +export const switcherInputMap = pgTable( + "switcher_input_map", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + input: integer("input").notNull(), + roomId: varchar("room_id", { length: 160 }) + .notNull() + .references(() => rooms.id, { onDelete: "cascade" }), + switcherId: varchar("switcher_id", { length: 160 }) + .notNull() + .references(() => switchers.id, { onDelete: "cascade" }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.switcherId, table.input] }), + roomIdx: index("switcher_input_map_room_idx").on(table.roomId), + roomUnique: uniqueIndex("switcher_input_map_room_unique").on(table.switcherId, table.roomId), + }), +); + +// Listener desk wired to a switcher output jack. One user per output and one +// output per user (per switcher). +export const switcherOutputMap = pgTable( + "switcher_output_map", + { + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + output: integer("output").notNull(), + switcherId: varchar("switcher_id", { length: 160 }) + .notNull() + .references(() => switchers.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.switcherId, table.output] }), + userIdx: index("switcher_output_map_user_idx").on(table.userId), + userUnique: uniqueIndex("switcher_output_map_user_unique").on(table.switcherId, table.userId), + }), +); diff --git a/packages/db/src/schema/uploads.ts b/packages/db/src/schema/uploads.ts new file mode 100644 index 0000000..6f2cb69 --- /dev/null +++ b/packages/db/src/schema/uploads.ts @@ -0,0 +1,99 @@ +import { + boolean, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + varchar, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; + +export const uploadProviders = pgTable("upload_providers", { + // Non-secret typed connection config (smb/s3 fields). + config: jsonb("config") + .notNull() + .default(sql`'{}'::jsonb`), + displayName: varchar("display_name", { length: 160 }).notNull(), + enabled: boolean("enabled").notNull().default(false), + provider: varchar("provider", { length: 32 }).primaryKey(), + // Encrypted secret material (smbPassword, s3SecretAccessKey). + secrets: jsonb("secrets") + .notNull() + .default(sql`'{}'::jsonb`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +// Named SMB/S3 upload targets. Unlike upload_providers (one row per kind), many +// destinations of each kind may exist; upload policies reference one by id. +export const uploadDestinations = pgTable("upload_destinations", { + // Non-secret typed connection config (smb/s3 fields). + config: jsonb("config") + .notNull() + .default(sql`'{}'::jsonb`), + displayName: varchar("display_name", { length: 160 }).notNull(), + enabled: boolean("enabled").notNull().default(false), + id: varchar("id", { length: 160 }).primaryKey(), + kind: varchar("kind", { length: 32 }).notNull(), + // Encrypted secret material (smbPassword, s3SecretAccessKey). + secrets: jsonb("secrets") + .notNull() + .default(sql`'{}'::jsonb`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const uploadPolicies = pgTable("upload_policies", { + deleteCacheAfterUpload: boolean("delete_cache_after_upload").notNull().default(false), + destinationId: varchar("destination_id", { length: 160 }), + enabled: boolean("enabled").notNull().default(false), + id: varchar("id", { length: 160 }).primaryKey(), + maxAttempts: integer("max_attempts").notNull().default(5), + name: varchar("name", { length: 160 }).notNull(), + pathOverride: text("path_override"), + // Legacy columns superseded by destinationId/pathOverride; retained nullable for + // backfill and dropped in a later migration. + provider: varchar("provider", { length: 32 }), + target: text("target"), + trigger: varchar("trigger", { length: 40 }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const uploadQueueItems = pgTable( + "upload_queue_items", + { + attemptCount: integer("attempt_count").notNull().default(0), + cachePath: text("cache_path"), + checksum: varchar("checksum", { length: 160 }), + // Chunk this upload item belongs to. NULL = legacy whole-recording item. + chunkId: varchar("chunk_id", { length: 160 }), + chunkIndex: integer("chunk_index"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), + destinationId: varchar("destination_id", { length: 160 }), + fileName: text("file_name").notNull(), + id: varchar("id", { length: 160 }).primaryKey(), + lastError: text("last_error"), + maxAttempts: integer("max_attempts").notNull().default(5), + nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull(), + pathOverride: text("path_override"), + provider: varchar("provider", { length: 32 }).notNull(), + recordingId: varchar("recording_id", { length: 160 }).notNull(), + status: varchar("status", { length: 32 }).notNull(), + target: text("target"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), + uploadPolicyId: varchar("upload_policy_id", { length: 160 }), + }, + (table) => ({ + chunkIdx: index("upload_queue_items_chunk_idx").on(table.chunkId), + destinationStatusIdx: index("upload_queue_items_destination_status_idx").on( + table.destinationId, + table.status, + ), + dueIdx: index("upload_queue_items_due_idx").on(table.status, table.nextAttemptAt), + providerStatusIdx: index("upload_queue_items_provider_status_idx").on( + table.provider, + table.status, + ), + recordingIdx: index("upload_queue_items_recording_idx").on(table.recordingId), + }), +); diff --git a/packages/shared/src/audit.ts b/packages/shared/src/audit.ts new file mode 100644 index 0000000..d98da13 --- /dev/null +++ b/packages/shared/src/audit.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +import { isoDateTimeSchema } from "./base.js"; +import { permissionSchema, roleSchema } from "./rbac.js"; + +// Immutable audit record appended for every privileged action and denied +// attempt. `before`/`after` capture resource state diffs; `correlationIds` ties +// an event to runner runs, jobs, and sessions. +export const auditOutcomeSchema = z.enum(["allowed", "denied", "failed", "partial", "succeeded"]); +export const auditActorTypeSchema = z.enum(["node", "system", "user"]); + +export const auditEventSchema = z.object({ + action: z.string().min(1), + actor: z.object({ + id: z.string().min(1), + name: z.string().min(1), + roles: z.array(roleSchema), + type: auditActorTypeSchema, + }), + actorContext: z.object({ + ipAddress: z.string().optional(), + sessionId: z.string().optional(), + userAgent: z.string().optional(), + }), + after: z.record(z.string(), z.unknown()).optional(), + before: z.record(z.string(), z.unknown()).optional(), + correlationIds: z.record(z.string(), z.string()).optional(), + createdAt: isoDateTimeSchema, + details: z.record(z.string(), z.unknown()), + id: z.string().min(1), + outcome: auditOutcomeSchema, + permission: permissionSchema.optional(), + reason: z.string().optional(), + target: z.object({ + id: z.string().optional(), + name: z.string().optional(), + type: z.string().min(1), + }), +}); + +export type AuditActorType = z.infer; +export type AuditEvent = z.infer; +export type AuditOutcome = z.infer; diff --git a/packages/shared/src/base.ts b/packages/shared/src/base.ts index 14a21c8..baa2b19 100644 --- a/packages/shared/src/base.ts +++ b/packages/shared/src/base.ts @@ -43,3 +43,14 @@ export const uploadQueueStatusSchema = z.enum([ "succeeded", "cancelled", ]); + +// ISO 8601 calendar date with no time component (e.g. schedule exception dates). +export const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +// 24-hour wall-clock time "HH:MM" used by recurring schedule windows. +export const timeOfDaySchema = z.string().regex(/^\d{2}:\d{2}$/); +// Linux audio capture backend selected per node, schedule, or job. +export const audioCaptureBackendSchema = z.enum(["alsa", "jack", "pipewire"]); + +export type HealthSeverity = z.infer; +export type UploadProvider = z.infer; +export type UploadQueueStatus = z.infer; diff --git a/packages/shared/src/channel-maps.ts b/packages/shared/src/channel-maps.ts new file mode 100644 index 0000000..a55055a --- /dev/null +++ b/packages/shared/src/channel-maps.ts @@ -0,0 +1,110 @@ +import { z } from "zod"; + +import { isoDateTimeSchema } from "./base.js"; +import { channelModeSchema } from "./channels.js"; + +// A channel-map template maps captured source channels onto output channels, and +// is assignable to an interface or a whole node. Assignments carry history and +// can be staged as a plan before being applied. +export const templateAssignmentTargetSchema = z.enum(["interface", "node"]); + +export const channelMapEntrySchema = z.object({ + included: z.boolean(), + label: z.string().trim().min(1).max(160), + outputChannelIndex: z.number().int().positive().optional(), + sourceChannelIndex: z.number().int().positive(), +}); +export const channelMapTemplateSchema = z.object({ + channelMode: channelModeSchema, + entries: z.array(channelMapEntrySchema).min(1).max(128), + id: z.string().min(1), + name: z.string().min(1), + promotedAt: isoDateTimeSchema.optional(), + promotedFromTemplateId: z.string().min(1).optional(), + revision: z.number().int().positive().default(1), + tags: z.array(z.string().min(1)).default([]), +}); +export const channelMapTemplateInputSchema = z.object({ + channelMode: channelModeSchema.default("mono_to_stereo_mix"), + entries: z.array(channelMapEntrySchema).min(1).max(128), + id: z.string().trim().min(1).max(160).optional(), + name: z.string().trim().min(1).max(160), + tags: z.array(z.string().trim().min(1).max(80)).max(64).default([]), +}); +export const channelMapTemplateUpdateSchema = z + .object({ + channelMode: channelModeSchema.optional(), + entries: z.array(channelMapEntrySchema).min(1).max(128).optional(), + name: z.string().trim().min(1).max(160).optional(), + tags: z.array(z.string().trim().min(1).max(80)).max(64).optional(), + }) + .refine((value) => Object.keys(value).length > 0, "At least one channel map field is required"); +export const channelMapAssignmentHistorySchema = z.object({ + actorUserId: z.string().optional(), + changedAt: isoDateTimeSchema, + id: z.string().min(1), + nextTemplateId: z.string().min(1), + previousTemplateId: z.string().min(1).optional(), + reason: z.enum(["assigned", "rolled_back"]), +}); +export const channelMapTemplateAssignmentSchema = z.object({ + assignedAt: isoDateTimeSchema, + history: z.array(channelMapAssignmentHistorySchema).default([]), + id: z.string().min(1), + targetId: z.string().min(1), + targetType: templateAssignmentTargetSchema, + templateId: z.string().min(1), +}); +export const channelMapTemplateAssignmentInputSchema = z.object({ + targetId: z.string().trim().min(1).max(160), + targetType: templateAssignmentTargetSchema, + templateId: z.string().trim().min(1).max(160), +}); +export const channelMapAssignmentTargetInputSchema = z.object({ + targetId: z.string().trim().min(1).max(160), + targetType: templateAssignmentTargetSchema, +}); +export const channelMapTemplateAssignmentBulkInputSchema = z.object({ + targets: z.array(channelMapAssignmentTargetInputSchema).min(1).max(128), + templateId: z.string().trim().min(1).max(160), +}); +export const channelMapAssignmentPlanStatusSchema = z.enum(["applied", "cancelled", "pending"]); +export const channelMapAssignmentPlanInputSchema = z.object({ + note: z.string().trim().max(500).optional(), + targets: z.array(channelMapAssignmentTargetInputSchema).min(1).max(128), + templateId: z.string().trim().min(1).max(160), +}); +export const channelMapAssignmentPlanSchema = z.object({ + appliedAt: isoDateTimeSchema.optional(), + appliedByUserId: z.string().optional(), + cancelledAt: isoDateTimeSchema.optional(), + createdAt: isoDateTimeSchema, + createdByUserId: z.string().optional(), + id: z.string().min(1), + note: z.string().optional(), + status: channelMapAssignmentPlanStatusSchema, + targets: z.array(channelMapAssignmentTargetInputSchema).min(1).max(128), + templateId: z.string().min(1), +}); +export const channelMapTemplateAssignmentRollbackInputSchema = z.object({ + targetId: z.string().trim().min(1).max(160), + targetType: templateAssignmentTargetSchema, +}); + +export type ChannelMapAssignmentHistory = z.infer; +export type ChannelMapAssignmentPlan = z.infer; +export type ChannelMapAssignmentPlanInput = z.infer; +export type ChannelMapEntry = z.infer; +export type ChannelMapTemplate = z.infer; +export type ChannelMapTemplateAssignment = z.infer; +export type ChannelMapTemplateAssignmentInput = z.infer< + typeof channelMapTemplateAssignmentInputSchema +>; +export type ChannelMapTemplateAssignmentBulkInput = z.infer< + typeof channelMapTemplateAssignmentBulkInputSchema +>; +export type ChannelMapTemplateAssignmentRollbackInput = z.infer< + typeof channelMapTemplateAssignmentRollbackInputSchema +>; +export type ChannelMapTemplateInput = z.infer; +export type ChannelMapTemplateUpdate = z.infer; diff --git a/packages/shared/src/controller-settings.ts b/packages/shared/src/controller-settings.ts new file mode 100644 index 0000000..bcb3a8e --- /dev/null +++ b/packages/shared/src/controller-settings.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +// Day the operator console's schedule calendar starts its week on. +export const weekStartDaySchema = z.enum([ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +]); +export const controllerSettingsSchema = z.object({ + controllerName: z.string().trim().min(1).max(160), + // Operator-chosen defaults that pre-fill the scheduling and ad-hoc recording + // forms. `null` means "no default set" (the forms fall back to the built-in + // profile/policy). One default per type keeps the "set as default" toggle + // single-select without per-policy flags. + defaultRecordingProfileId: z.string().trim().min(1).max(160).nullable().default(null), + defaultRetentionPolicyId: z.string().trim().min(1).max(160).nullable().default(null), + defaultUploadPolicyId: z.string().trim().min(1).max(160).nullable().default(null), + defaultWatchdogPolicyId: z.string().trim().min(1).max(160).nullable().default(null), + weekStartsOn: weekStartDaySchema.default("monday"), +}); +export const controllerSettingsUpdateSchema = z + .object({ + controllerName: z.string().trim().min(1).max(160).optional(), + // Nullable so an operator can clear a default (send `null`); omitted keeps + // the current value. + defaultRecordingProfileId: z.string().trim().min(1).max(160).nullable().optional(), + defaultRetentionPolicyId: z.string().trim().min(1).max(160).nullable().optional(), + defaultUploadPolicyId: z.string().trim().min(1).max(160).nullable().optional(), + defaultWatchdogPolicyId: z.string().trim().min(1).max(160).nullable().optional(), + weekStartsOn: weekStartDaySchema.optional(), + }) + .refine((value) => Object.keys(value).length > 0, "At least one controller setting is required"); +export const defaultControllerSettings = controllerSettingsSchema.parse({ + controllerName: "Rakkr Controller", + weekStartsOn: "monday", +}); + +export type ControllerSettings = z.infer; +export type ControllerSettingsUpdate = z.infer; +export type WeekStartDay = z.infer; diff --git a/packages/shared/src/health-events.ts b/packages/shared/src/health-events.ts new file mode 100644 index 0000000..fc58682 --- /dev/null +++ b/packages/shared/src/health-events.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +import { healthSeveritySchema, isoDateTimeSchema } from "./base.js"; + +export const healthEventStatusSchema = z.enum(["open", "acknowledged", "suppressed", "resolved"]); + +export const healthEventSchema = z.object({ + acknowledgedAt: isoDateTimeSchema.nullable(), + acknowledgedBy: z.string().optional(), + details: z.record(z.string(), z.unknown()), + id: z.string().min(1), + nodeId: z.string().min(1).optional(), + openedAt: isoDateTimeSchema, + recordingId: z.string().optional(), + resolvedAt: isoDateTimeSchema.nullable(), + resolvedBy: z.string().optional(), + scheduleId: z.string().optional(), + severity: healthSeveritySchema, + status: healthEventStatusSchema, + suppressedAt: isoDateTimeSchema.nullable(), + suppressedBy: z.string().optional(), + suppressedUntil: isoDateTimeSchema.nullable(), + type: z.string().min(1), +}); + +export type HealthEvent = z.infer; +export type HealthEventStatus = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 317e8e4..ff95bcd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,997 +1,29 @@ -import { z } from "zod"; - -import { - dbfsSchema, - healthSeveritySchema, - ianaTimeZoneSchema, - isoDateTimeSchema, - uploadProviderSchema, - uploadQueueStatusSchema, -} from "./base.js"; -import { captureChannelSelectionSchema, channelModeSchema } from "./channels.js"; -import { recordingEnhancementSchema } from "./enhancement.js"; -import { recordingChunkSchema } from "./recording-chunks.js"; -import { accessGroupIdSchema, accessGroupSchema } from "./access-groups.js"; +// Public contract surface for @rakkr/shared. Each domain lives in its own module +// (see base.ts for the shared leaf primitives); this file only re-exports them so +// consumers keep importing everything from the package root. export * from "./access-groups.js"; export * from "./agent-version.js"; +export * from "./audit.js"; export * from "./base.js"; +export * from "./channel-maps.js"; export * from "./channels.js"; +export * from "./controller-settings.js"; export * from "./enhancement.js"; export * from "./health-event-summary.js"; +export * from "./health-events.js"; +export * from "./nodes.js"; export * from "./oidc.js"; export * from "./pagination.js"; +export * from "./rbac.js"; export * from "./recording-chunks.js"; export * from "./recording-job-summary.js"; export * from "./recording-profile.js"; +export * from "./recordings.js"; +export * from "./retention.js"; export * from "./room-capabilities.js"; export * from "./rooms.js"; +export * from "./schedules.js"; export * from "./switchers.js"; export * from "./upload-providers.js"; +export * from "./uploads.js"; export * from "./watchdog-policy.js"; - -export const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); -const audioCaptureBackendSchema = z.enum(["alsa", "jack", "pipewire"]); -const timeOfDaySchema = z.string().regex(/^\d{2}:\d{2}$/); - -// "provisioning" is an enrolled node that has never made contact (no bootstrap, -// no heartbeat). It is excluded from liveness/offline monitoring until its first -// heartbeat flips it to a live status — see node-liveness / watchdog-node-liveness. -export const nodeStatusSchema = z.enum([ - "provisioning", - "online", - "offline", - "degraded", - "recording", - "alerting", -]); - -// A node is "reachable" (in contact and reporting) when its status is online, -// recording, degraded, or alerting. "offline" (heartbeat gone stale) and -// "provisioning" (enrolled but never contacted) are NOT reachable. Shared by the -// /metrics `rakkr_node_online` gauge and the dashboard active-node count so the -// two cannot diverge on how a never-contacted provisioning node is treated -// (a naive `status !== "offline"` counts provisioning as online — see audit N1/N2). -export function isNodeReachable(status: NodeStatus): boolean { - return ( - status === "online" || status === "recording" || status === "degraded" || status === "alerting" - ); -} - -export const healthEventStatusSchema = z.enum(["open", "acknowledged", "suppressed", "resolved"]); - -export const recordingSourceSchema = z.enum(["ad_hoc", "schedule"]); -export const recordingStatusSchema = z.enum([ - "queued", - "recording", - "completed", - "failed", - "cached", - "uploaded", - "partial", -]); -export const recordingJobStatusSchema = z.enum([ - "queued", - "running", - "stop_requested", - "cancelled", - "completed", - "failed", -]); - -export const templateAssignmentTargetSchema = z.enum(["interface", "node"]); - -export const permissions = [ - "audit:read", - "auth:manage", - "health:acknowledge", - "health:read", - "listen:monitor", - "metrics:read", - "node:control", - "node:manage", - "node:read", - "recording:control", - "recording:create", - "recording:delete", - "recording:download", - "recording:edit", - "recording:playback", - "recording:read", - "schedule:manage", - "schedule:read", - "settings:manage", - "settings:read", - "switcher:manage", - "switcher:map", - "switcher:read", - "system:admin", -] as const; - -export const roles = ["owner", "admin", "operator", "viewer", "auditor"] as const; -export type Permission = (typeof permissions)[number]; -export type Role = (typeof roles)[number]; -export const permissionSchema = z.enum(permissions); -export const roleSchema = z.enum(roles); -export const accessPolicyEffectSchema = z.enum(["allow", "deny"]); -export const accessPolicySubjectTypeSchema = z.enum(["user", "group", "everyone"]); -export const resourceGrantSchema = z.object({ - resourceId: z.string().min(1), - resourceType: z.string().min(1), -}); -export const accessPolicySchema = z.object({ - effect: accessPolicyEffectSchema, - id: z.string().min(1), - reason: z.string().optional(), - resourceId: z.string().min(1), - resourceType: z.string().min(1), - subjectId: z.string().optional(), - subjectType: accessPolicySubjectTypeSchema, -}); -export const accessPolicyInputSchema = accessPolicySchema.omit({ id: true }); -export const rolePermissions: Record = { - admin: permissions.filter((permission) => permission !== "system:admin"), - auditor: ["audit:read", "health:read", "metrics:read", "recording:read"], - operator: [ - "health:acknowledge", - "health:read", - "listen:monitor", - "metrics:read", - "node:control", - "node:read", - "recording:control", - "recording:create", - "recording:download", - "recording:edit", - "recording:playback", - "recording:read", - "schedule:manage", - "schedule:read", - "settings:read", - "switcher:map", - "switcher:read", - ], - owner: permissions, - viewer: [ - "health:read", - "metrics:read", - "node:read", - "recording:download", - "recording:playback", - "recording:read", - "schedule:read", - "settings:read", - "switcher:read", - ], -}; - -export function hasPermission(role: Role, permission: Permission) { - return rolePermissions[role].includes(permission); -} - -export function hasAnyPermission(role: Role, required: Permission[]) { - return required.some((permission) => hasPermission(role, permission)); -} - -export const auditOutcomeSchema = z.enum(["allowed", "denied", "failed", "partial", "succeeded"]); -export const auditActorTypeSchema = z.enum(["node", "system", "user"]); - -export const currentUserSchema = z.object({ - disabledAt: isoDateTimeSchema.optional(), - email: z.string().email(), - groups: z.array(accessGroupSchema), - id: z.string().min(1), - name: z.string().min(1), - permissions: z.array(permissionSchema), - provider: z.enum(["local", "oidc"]), - resourceGrants: z.array(resourceGrantSchema), - roles: z.array(roleSchema), -}); - -export const auditEventSchema = z.object({ - action: z.string().min(1), - actor: z.object({ - id: z.string().min(1), - name: z.string().min(1), - roles: z.array(roleSchema), - type: auditActorTypeSchema, - }), - actorContext: z.object({ - ipAddress: z.string().optional(), - sessionId: z.string().optional(), - userAgent: z.string().optional(), - }), - after: z.record(z.string(), z.unknown()).optional(), - before: z.record(z.string(), z.unknown()).optional(), - correlationIds: z.record(z.string(), z.string()).optional(), - createdAt: isoDateTimeSchema, - details: z.record(z.string(), z.unknown()), - id: z.string().min(1), - outcome: auditOutcomeSchema, - permission: permissionSchema.optional(), - reason: z.string().optional(), - target: z.object({ - id: z.string().optional(), - name: z.string().optional(), - type: z.string().min(1), - }), -}); - -export const audioChannelSchema = z.object({ - alias: z.string().min(1), - index: z.number().int().positive(), - // Room that owns this channel. Room ownership is per-channel: any set of a - // node's channels can belong to a room, and each channel belongs to at most one - // room. Absent means the channel inherits the node default room. `roomName` is a - // denormalized display copy resolved by the controller. - roomId: z.string().min(1).optional(), - roomName: z.string().min(1).optional(), -}); - -export const audioInterfaceSchema = z.object({ - absent: z.boolean().optional(), // flagged absent by inventory reconcile; see node-store - alias: z.string().min(1), - backend: z.enum(["alsa", "jack", "pipewire", "unknown"]), - channelCount: z.number().int().nonnegative(), - channels: z.array(audioChannelSchema), - hardwarePath: z.string().min(1).optional(), - id: z.string().min(1), - sampleRates: z.array(z.number().int().positive()), - serialNumber: z.string().min(1).optional(), - systemName: z.string().min(1), - systemRef: z.string().min(1).optional(), -}); -export const nodeRuntimeSchema = z.object({ - architecture: z.string().min(1).optional(), - audioBackends: z.array(z.enum(["alsa", "jack", "pipewire", "unknown"])).default([]), - kernelRelease: z.string().min(1).optional(), - osName: z.string().min(1).optional(), - uptimeSeconds: z.number().int().nonnegative().optional(), -}); -export const defaultNodeRecordingCapacity = { maxConcurrentRecordings: 1 } as const; -export const nodeRecordingCapacitySchema = z.object({ - maxConcurrentRecordings: z.number().int().positive().max(128), -}); -export const nodeAudioCommandDefaultsSchema = z.object({ - captureArgsTemplate: z.string().trim().min(1).max(1000).optional(), - captureBackend: audioCaptureBackendSchema.optional(), - captureChannels: z.number().int().positive().max(256).optional(), - captureCommand: z.string().trim().min(1).max(255).optional(), - captureDevice: z.string().trim().min(1).max(255).optional(), - captureFormat: z.string().trim().min(1).max(80).optional(), - captureSampleRate: z.number().int().positive().max(384_000).optional(), - meterArgsTemplate: z.string().trim().min(1).max(1000).optional(), -}); - -export const audioQualitySchema = z.object({ - channelCorrelation: z - .object({ - peerChannelIndex: z.number().int().positive(), - phase: z.enum(["same", "inverted"]), - score: z.number().min(-1).max(1), - }) - .optional(), - broadbandNoiseScore: z.number().min(0).max(1).optional(), - crestFactorDb: z.number().min(0).max(80), - estimatedSnrDb: z.number().min(0).max(80).optional(), - humScore: z.number().min(0).max(1).optional(), - intelligibilityScore: z.number().min(0).max(1).optional(), - noiseScore: z.number().min(0).max(1), - speechLike: z.boolean(), - speechScore: z.number().min(0).max(1), - staticScore: z.number().min(0).max(1).optional(), - zeroCrossingRate: z.number().min(0).max(1), -}); -export const recorderNodeSchema = z.object({ - agentVersion: z.string().min(1), - alias: z.string().min(1), - hostname: z.string().min(1), - id: z.string().min(1), - interfaces: z.array(audioInterfaceSchema), - ipAddresses: z.array(z.string().min(1)), - lastSeenAt: isoDateTimeSchema, - location: z.object({ - building: z.string().optional(), - floor: z.string().optional(), - room: z.string().min(1), - site: z.string().min(1), - }), - // Node default room. Room ownership is per-channel (see audioChannelSchema); - // this is the fallback room for channels with no room of their own, and the - // display fallback for a node whose channels are all in one room. `location` - // above is retained for physical-install display. - roomId: z.string().min(1).optional(), - notes: z.string().optional(), - audioDefaults: nodeAudioCommandDefaultsSchema.optional(), - recordingCapacity: nodeRecordingCapacitySchema.optional(), - runtime: nodeRuntimeSchema.optional(), - status: nodeStatusSchema, - tags: z.array(z.string().min(1)), -}); - -export const audioLevelSchema = z.object({ - channelIndex: z.number().int().positive(), - clipping: z.boolean(), - label: z.string().min(1), - peakDbfs: dbfsSchema, - quality: audioQualitySchema.optional(), - rmsDbfs: dbfsSchema, -}); - -export const meterFrameSchema = z.object({ - capturedAt: isoDateTimeSchema, - interfaceId: z.string().min(1), - // Real interfaces have at most a few dozen channels (X32 = 32). Cap the array - // so a malformed/hostile node frame cannot wedge the watchdog's - // `Math.max(...levels)` spread with a RangeError — which would poison the - // stored `latest` frame and silently disable that node's watchdog. - levels: z.array(audioLevelSchema).max(512), - nodeId: z.string().min(1), -}); - -// Day the operator console's schedule calendar starts its week on. -export const weekStartDaySchema = z.enum([ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", -]); -export const controllerSettingsSchema = z.object({ - controllerName: z.string().trim().min(1).max(160), - // Operator-chosen defaults that pre-fill the scheduling and ad-hoc recording - // forms. `null` means "no default set" (the forms fall back to the built-in - // profile/policy). One default per type keeps the "set as default" toggle - // single-select without per-policy flags. - defaultRecordingProfileId: z.string().trim().min(1).max(160).nullable().default(null), - defaultRetentionPolicyId: z.string().trim().min(1).max(160).nullable().default(null), - defaultUploadPolicyId: z.string().trim().min(1).max(160).nullable().default(null), - defaultWatchdogPolicyId: z.string().trim().min(1).max(160).nullable().default(null), - weekStartsOn: weekStartDaySchema.default("monday"), -}); -export const controllerSettingsUpdateSchema = z - .object({ - controllerName: z.string().trim().min(1).max(160).optional(), - // Nullable so an operator can clear a default (send `null`); omitted keeps - // the current value. - defaultRecordingProfileId: z.string().trim().min(1).max(160).nullable().optional(), - defaultRetentionPolicyId: z.string().trim().min(1).max(160).nullable().optional(), - defaultUploadPolicyId: z.string().trim().min(1).max(160).nullable().optional(), - defaultWatchdogPolicyId: z.string().trim().min(1).max(160).nullable().optional(), - weekStartsOn: weekStartDaySchema.optional(), - }) - .refine((value) => Object.keys(value).length > 0, "At least one controller setting is required"); -export const defaultControllerSettings = controllerSettingsSchema.parse({ - controllerName: "Rakkr Controller", - weekStartsOn: "monday", -}); -export const channelMapEntrySchema = z.object({ - included: z.boolean(), - label: z.string().trim().min(1).max(160), - outputChannelIndex: z.number().int().positive().optional(), - sourceChannelIndex: z.number().int().positive(), -}); -export const channelMapTemplateSchema = z.object({ - channelMode: channelModeSchema, - entries: z.array(channelMapEntrySchema).min(1).max(128), - id: z.string().min(1), - name: z.string().min(1), - promotedAt: isoDateTimeSchema.optional(), - promotedFromTemplateId: z.string().min(1).optional(), - revision: z.number().int().positive().default(1), - tags: z.array(z.string().min(1)).default([]), -}); -export const channelMapTemplateInputSchema = z.object({ - channelMode: channelModeSchema.default("mono_to_stereo_mix"), - entries: z.array(channelMapEntrySchema).min(1).max(128), - id: z.string().trim().min(1).max(160).optional(), - name: z.string().trim().min(1).max(160), - tags: z.array(z.string().trim().min(1).max(80)).max(64).default([]), -}); -export const channelMapTemplateUpdateSchema = z - .object({ - channelMode: channelModeSchema.optional(), - entries: z.array(channelMapEntrySchema).min(1).max(128).optional(), - name: z.string().trim().min(1).max(160).optional(), - tags: z.array(z.string().trim().min(1).max(80)).max(64).optional(), - }) - .refine((value) => Object.keys(value).length > 0, "At least one channel map field is required"); -export const channelMapAssignmentHistorySchema = z.object({ - actorUserId: z.string().optional(), - changedAt: isoDateTimeSchema, - id: z.string().min(1), - nextTemplateId: z.string().min(1), - previousTemplateId: z.string().min(1).optional(), - reason: z.enum(["assigned", "rolled_back"]), -}); -export const channelMapTemplateAssignmentSchema = z.object({ - assignedAt: isoDateTimeSchema, - history: z.array(channelMapAssignmentHistorySchema).default([]), - id: z.string().min(1), - targetId: z.string().min(1), - targetType: templateAssignmentTargetSchema, - templateId: z.string().min(1), -}); -export const channelMapTemplateAssignmentInputSchema = z.object({ - targetId: z.string().trim().min(1).max(160), - targetType: templateAssignmentTargetSchema, - templateId: z.string().trim().min(1).max(160), -}); -export const channelMapAssignmentTargetInputSchema = z.object({ - targetId: z.string().trim().min(1).max(160), - targetType: templateAssignmentTargetSchema, -}); -export const channelMapTemplateAssignmentBulkInputSchema = z.object({ - targets: z.array(channelMapAssignmentTargetInputSchema).min(1).max(128), - templateId: z.string().trim().min(1).max(160), -}); -export const channelMapAssignmentPlanStatusSchema = z.enum(["applied", "cancelled", "pending"]); -export const channelMapAssignmentPlanInputSchema = z.object({ - note: z.string().trim().max(500).optional(), - targets: z.array(channelMapAssignmentTargetInputSchema).min(1).max(128), - templateId: z.string().trim().min(1).max(160), -}); -export const channelMapAssignmentPlanSchema = z.object({ - appliedAt: isoDateTimeSchema.optional(), - appliedByUserId: z.string().optional(), - cancelledAt: isoDateTimeSchema.optional(), - createdAt: isoDateTimeSchema, - createdByUserId: z.string().optional(), - id: z.string().min(1), - note: z.string().optional(), - status: channelMapAssignmentPlanStatusSchema, - targets: z.array(channelMapAssignmentTargetInputSchema).min(1).max(128), - templateId: z.string().min(1), -}); -export const channelMapTemplateAssignmentRollbackInputSchema = z.object({ - targetId: z.string().trim().min(1).max(160), - targetType: templateAssignmentTargetSchema, -}); -export const scheduleDayOfWeekSchema = z.enum([ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", -]); -export const scheduleExceptionSchema = z.discriminatedUnion("action", [ - z.object({ - action: z.literal("skip"), - date: isoDateSchema, - reason: z.string().trim().max(240).optional(), - }), - z - .object({ - action: z.literal("pause"), - endDate: isoDateSchema, - reason: z.string().trim().max(240).optional(), - startDate: isoDateSchema, - }) - .refine((value) => value.startDate <= value.endDate, "Pause start must be before end"), -]); -const scheduleRecurrenceOptions = { - exceptions: z.array(scheduleExceptionSchema).max(366).optional(), - startEarlySeconds: z.number().int().nonnegative().max(86_400).optional(), - stopLateSeconds: z.number().int().nonnegative().max(86_400).optional(), -}; -export const scheduleRecurrenceSchema = z.discriminatedUnion("mode", [ - z.object({ - mode: z.literal("manual"), - ...scheduleRecurrenceOptions, - }), - z.object({ - // Optional fixed recording length (seconds) for a single-fire schedule. - // Set when a timed recurring occurrence is moved into a one-off so the - // moved recording keeps its original duration; absent = open-ended. - durationSeconds: z.number().int().positive().max(2_678_400).optional(), - mode: z.literal("once"), - startsAt: isoDateTimeSchema, - ...scheduleRecurrenceOptions, - }), - z.object({ - endTime: timeOfDaySchema, - interval: z.number().int().positive(), - mode: z.literal("daily"), - startTime: timeOfDaySchema, - ...scheduleRecurrenceOptions, - }), - z.object({ - daysOfWeek: z.array(scheduleDayOfWeekSchema).min(1).max(7), - endTime: timeOfDaySchema, - interval: z.number().int().positive(), - mode: z.literal("weekly"), - startTime: timeOfDaySchema, - ...scheduleRecurrenceOptions, - }), - z.object({ - dayOfMonth: z.number().int().min(1).max(31), - endTime: timeOfDaySchema, - interval: z.number().int().positive(), - mode: z.literal("monthly"), - startTime: timeOfDaySchema, - ...scheduleRecurrenceOptions, - }), - z.object({ - mode: z.literal("always_on"), - ...scheduleRecurrenceOptions, - }), -]); - -export const scheduleSummarySchema = z.object({ - assignedGroupIds: z.array(accessGroupIdSchema).default([]), - assignedUserIds: z.array(z.string().trim().min(1).max(160)).default([]), - captureBackend: audioCaptureBackendSchema.optional(), - captureChannelSelection: captureChannelSelectionSchema.optional(), - captureInterfaceId: z.string().min(1).optional(), - channelMode: channelModeSchema.optional(), - enabled: z.boolean(), - folderTemplate: z.string().min(1), - id: z.string().min(1), - name: z.string().min(1), - nextRunAt: isoDateTimeSchema.optional(), - nodeId: z.string().min(1), - recurrence: scheduleRecurrenceSchema.default({ mode: "manual" }), - recordingProfileId: z.string().min(1), - retentionPolicyId: z.string().min(1).default("retention-keep-controller-cache"), - room: z.string().min(1), - roomId: z.string().min(1).optional(), - tags: z.array(z.string().min(1)), - timezone: z.string().min(1), - titleTemplate: z.string().min(1), - uploadPolicyIds: z.array(z.string().min(1)).default([]), - watchdogPolicyId: z.string().min(1), -}); -export const scheduleInputSchema = z.object({ - assignedGroupIds: z.array(accessGroupIdSchema).max(128).default([]), - assignedUserIds: z.array(z.string().trim().min(1).max(160)).max(256).default([]), - captureBackend: audioCaptureBackendSchema.nullable().optional(), - captureChannelSelection: captureChannelSelectionSchema.nullable().optional(), - captureInterfaceId: z.string().trim().min(1).max(160).nullable().optional(), - channelMode: channelModeSchema.nullable().optional(), - enabled: z.boolean().default(true), - folderTemplate: z.string().trim().min(1).max(500), - id: z.string().trim().min(1).max(160).optional(), - name: z.string().trim().min(1).max(160), - nextRunAt: isoDateTimeSchema.optional(), - nodeId: z.string().trim().min(1).max(160), - recurrence: scheduleRecurrenceSchema.optional(), - recordingProfileId: z.string().trim().min(1).max(160), - retentionPolicyId: z.string().trim().min(1).max(160).default("retention-keep-controller-cache"), - room: z.string().trim().min(1).max(160), - roomId: z.string().trim().min(1).max(160).optional(), - tags: z.array(z.string().trim().min(1).max(80)).max(64).default([]), - timezone: ianaTimeZoneSchema, - titleTemplate: z.string().trim().min(1).max(500), - // Capped + deduped on write: each recording fans out to one upload queue item - // per id, so an uncapped list multiplies queue work per recording (audit R3-4). - uploadPolicyIds: z.array(z.string().trim().min(1).max(160)).max(32).default([]), - watchdogPolicyId: z.string().trim().min(1).max(160), -}); -export const scheduleUpdateSchema = z - .object({ - assignedGroupIds: z.array(accessGroupIdSchema).max(128).optional(), - assignedUserIds: z.array(z.string().trim().min(1).max(160)).max(256).optional(), - captureBackend: audioCaptureBackendSchema.nullable().optional(), - captureChannelSelection: captureChannelSelectionSchema.nullable().optional(), - captureInterfaceId: z.string().trim().min(1).max(160).nullable().optional(), - channelMode: channelModeSchema.nullable().optional(), - enabled: z.boolean().optional(), - folderTemplate: z.string().trim().min(1).max(500).optional(), - name: z.string().trim().min(1).max(160).optional(), - nextRunAt: isoDateTimeSchema.optional(), - nodeId: z.string().trim().min(1).max(160).optional(), - recurrence: scheduleRecurrenceSchema.optional(), - recordingProfileId: z.string().trim().min(1).max(160).optional(), - retentionPolicyId: z.string().trim().min(1).max(160).optional(), - room: z.string().trim().min(1).max(160).optional(), - roomId: z.string().trim().min(1).max(160).optional(), - tags: z.array(z.string().trim().min(1).max(80)).max(64).optional(), - timezone: ianaTimeZoneSchema.optional(), - titleTemplate: z.string().trim().min(1).max(500).optional(), - uploadPolicyIds: z.array(z.string().trim().min(1).max(160)).max(32).optional(), - watchdogPolicyId: z.string().trim().min(1).max(160).optional(), - }) - .refine((value) => Object.keys(value).length > 0, "At least one schedule field is required"); -export const scheduleOccurrencePreviewSchema = z.object({ - recordingEndAt: isoDateTimeSchema.optional(), - recordingStartAt: isoDateTimeSchema, - scheduledStartAt: isoDateTimeSchema.optional(), -}); -export const scheduleRecurrenceModeSchema = z.enum([ - "manual", - "once", - "daily", - "weekly", - "monthly", - "always_on", -]); -// A single occurrence event returned by the calendar endpoint. Extends the -// occurrence preview with the owning schedule's identity so the calendar can -// render and route interactions (recurrenceMode picks the drag behavior: -// once -> move in place, recurring -> split a single instance). -export const scheduleCalendarOccurrenceSchema = scheduleOccurrencePreviewSchema.extend({ - enabled: z.boolean(), - nodeId: z.string().min(1), - recurrenceMode: scheduleRecurrenceModeSchema, - room: z.string().min(1), - scheduleId: z.string().min(1), - scheduleName: z.string().min(1), - // The schedule's IANA timezone, so the calendar groups each chip on the - // SCHEDULE-local day (matching the backend skip/occurrence day) rather than the - // viewer's browser-local day when the two differ (see calendar-grid groupByLocalDay). - timezone: z.string().min(1).max(80), -}); -export const scheduleCalendarResponseSchema = z.object({ - data: z.array(scheduleCalendarOccurrenceSchema), - meta: z.object({ - end: isoDateTimeSchema, - occurrenceCount: z.number().int().nonnegative(), - scheduleCount: z.number().int().nonnegative(), - start: isoDateTimeSchema, - truncated: z.boolean(), - }), -}); - -export const recordingSummarySchema = z.object({ - cached: z.boolean(), - cachePath: z.string().min(1).optional(), - checksum: z.string().min(1).optional(), - chunks: z.array(recordingChunkSchema).optional(), - chunkSeconds: z.number().int().positive().optional(), - chunkTotal: z.number().int().positive().optional(), - durationSeconds: z.number().int().nonnegative(), - enhancedCachePath: z.string().min(1).optional(), - rawCachePath: z.string().min(1).optional(), - folder: z.string().min(1), - healthStatus: z.enum(["healthy", "warning", "critical", "unknown"]), - id: z.string().min(1), - name: z.string().min(1), - nodeId: z.string().min(1).optional(), - notes: z.string().max(2000).optional(), - recordedAt: isoDateTimeSchema, - recordingProfileId: z.string().min(1).optional(), - retentionPolicyId: z.string().min(1).optional(), - // Room that owns this recording, captured at create time from the selected - // channels' room. Persisted, not derived, so a later channel reassignment does - // not retroactively move a completed recording. - roomId: z.string().min(1).optional(), - scheduleId: z.string().min(1).optional(), - source: recordingSourceSchema, - status: recordingStatusSchema, - tags: z.array(z.string().min(1)), - transcriptSnippets: z.array(z.string().trim().min(1).max(500)).max(20).optional(), - trackGroupId: z.string().min(1).optional(), - trackIndex: z.number().int().positive().optional(), - trackTotal: z.number().int().positive().optional(), - uploadPolicyIds: z.array(z.string().min(1)).optional(), - watchdogPolicyId: z.string().min(1).optional(), - waveformPreview: z - .object({ - channelCount: z.number().int().positive(), - generatedAt: isoDateTimeSchema, - peaks: z.array(z.number().min(0).max(1)).min(1).max(256), - sampleCount: z.number().int().positive(), - sampleRate: z.number().int().positive(), - source: z.enum(["ffmpeg_decoded_peak", "wav_s16le_peak"]), - }) - .optional(), -}); -export const recordingJobChannelMapSchema = z.object({ - assignmentId: z.string().min(1), - channelMode: channelModeSchema, - entries: z.array(channelMapEntrySchema).min(1).max(128), - sourceChannels: z.number().int().positive(), - targetId: z.string().min(1), - targetType: templateAssignmentTargetSchema, - templateId: z.string().min(1), - templateName: z.string().min(1), -}); -export const recordingJobSchema = z.object({ - claimedBy: z.string().min(1).optional(), - command: z.object({ - captureBackend: audioCaptureBackendSchema.optional(), - captureChannels: z.number().int().positive(), - // Resolved 1-based source channels this job owns on the interface. Absent = - // whole interface (legacy). Drives both the channel map and the controller's - // per-channel conflict detection. - captureChannelSelection: captureChannelSelectionSchema.optional(), - captureDevice: z.string().min(1), - captureFormat: z.string().min(1), - // Jobs sharing an interface + capture window carry the same group id so the - // agent can capture the device once and split it into per-job renditions. - captureGroupId: z.string().min(1).optional(), - captureInterfaceId: z.string().min(1).optional(), - captureSampleRate: z.number().int().positive(), - channelMap: recordingJobChannelMapSchema.optional(), - chunkSeconds: z.number().int().positive().optional(), - durationSeconds: z.number().int().positive(), - enhancement: recordingEnhancementSchema.optional(), - outputBitrateKbps: z.number().int().positive().optional(), - outputCodec: z.enum(["mp3", "flac", "wav"]).optional(), - outputFileName: z.string().min(1), - outputVbr: z.boolean().optional(), - recorderCacheRetention: z - .object({ - deleteAfterUpload: z.boolean(), - maxAgeDays: z.number().int().positive().nullable().optional(), - maxBytes: z.number().int().positive().nullable().optional(), - minFreeDiskPercent: z.number().int().min(0).max(95).nullable().optional(), - policyId: z.string().min(1), - }) - .optional(), - trackGroupId: z.string().min(1).optional(), - trackIndex: z.number().int().positive().optional(), - trackTotal: z.number().int().positive().optional(), - type: z.literal("alsa_capture"), - }), - chunks: z.array(recordingChunkSchema).optional(), - chunkTotal: z.number().int().positive().optional(), - completedAt: isoDateTimeSchema.optional(), - createdAt: isoDateTimeSchema, - failureReason: z.string().optional(), - id: z.string().min(1), - lastHeartbeatAt: isoDateTimeSchema.optional(), - leaseExpiresAt: isoDateTimeSchema.optional(), - nodeId: z.string().min(1), - recordingId: z.string().min(1), - startedAt: isoDateTimeSchema.optional(), - status: recordingJobStatusSchema, - stopRequestedAt: isoDateTimeSchema.optional(), -}); -export const uploadQueueItemSchema = z.object({ - attemptCount: z.number().int().nonnegative(), - cachePath: z.string().min(1).optional(), - checksum: z.string().min(1).optional(), - // Set when this item uploads one recording chunk as its own object. NULL = - // legacy whole-recording item. - chunkId: z.string().min(1).optional(), - chunkIndex: z.number().int().positive().optional(), - createdAt: isoDateTimeSchema, - destinationId: z.string().min(1).optional(), - fileName: z.string().min(1).optional(), - id: z.string().min(1), - lastError: z.string().min(1).optional(), - maxAttempts: z.number().int().positive(), - nextAttemptAt: isoDateTimeSchema, - pathOverride: z.string().min(1).optional(), - provider: uploadProviderSchema, - recordingId: z.string().min(1), - status: uploadQueueStatusSchema, - target: z.string().min(1).optional(), - updatedAt: isoDateTimeSchema, - uploadPolicyId: z.string().min(1).optional(), -}); -export const uploadChecksumVerificationSchema = z.object({ - algorithm: z.literal("sha256"), - expected: z.string().min(1), - method: z.enum(["file_copy_sha256", "s3_checksum_sha256"]), - observed: z.string().min(1).optional(), - // `provider_declared` is the honest status for S3-compatible custom endpoints - // that may ignore the trailing ChecksumSHA256; only real AWS S3 validates it, - // which is `provider_validated`. `matched` is our own read-back byte compare. - status: z.enum(["matched", "provider_validated", "provider_declared"]), -}); -export const uploadQueueRunItemSchema = z.object({ - checksumVerification: uploadChecksumVerificationSchema.optional(), - itemId: z.string().min(1), - provider: uploadProviderSchema, - reason: z.string().min(1).optional(), - recordingId: z.string().min(1), - status: uploadQueueStatusSchema, -}); -export const uploadQueueRunSummarySchema = z.object({ - attempted: z.number().int().nonnegative(), - deferred: z.number().int().nonnegative(), - failed: z.number().int().nonnegative(), - items: z.array(uploadQueueRunItemSchema), - succeeded: z.number().int().nonnegative(), -}); -export const uploadRunnerStatusSchema = z.object({ - batchSize: z.number().int().positive(), - intervalSeconds: z.number().int().positive(), - lastRunAt: isoDateTimeSchema.optional(), - lastSummary: uploadQueueRunSummarySchema.optional(), - running: z.boolean(), - started: z.boolean(), -}); - -export const uploadPolicyTriggerSchema = z.enum(["manual", "on_recording_cached"]); -export const uploadPolicySchema = z.object({ - deleteCacheAfterUpload: z.boolean().default(false), - destinationId: z.string().min(1).optional(), - enabled: z.boolean(), - id: z.string().min(1), - maxAttempts: z.number().int().positive().max(100), - name: z.string().min(1), - pathOverride: z.string().min(1).max(500).optional(), - trigger: uploadPolicyTriggerSchema, - updatedAt: isoDateTimeSchema, -}); -export const uploadPolicyInputSchema = z.object({ - deleteCacheAfterUpload: z.boolean().default(false), - // Optional in the shared shape (the store is an internal seeding primitive), - // but the operator create route requires it — every policy must target a real - // destination or its recordings reconcile to `partial` (audit H3-3). - destinationId: z.string().trim().min(1).max(160).optional(), - enabled: z.boolean().default(true), - id: z.string().trim().min(1).max(160).optional(), - maxAttempts: z.number().int().positive().max(100).default(5), - name: z.string().trim().min(1).max(160), - pathOverride: z.string().trim().min(1).max(500).optional(), - trigger: uploadPolicyTriggerSchema.default("manual"), -}); -export const uploadPolicyUpdateSchema = z - .object({ - deleteCacheAfterUpload: z.boolean().optional(), - destinationId: z.string().trim().min(1).max(160).optional(), - enabled: z.boolean().optional(), - maxAttempts: z.number().int().positive().max(100).optional(), - name: z.string().trim().min(1).max(160).optional(), - pathOverride: z.string().trim().min(1).max(500).optional(), - trigger: uploadPolicyTriggerSchema.optional(), - }) - .refine((value) => Object.keys(value).length > 0, "At least one upload policy field is required"); - -export const retentionPolicyScopeSchema = z.enum(["controller_cache", "recorder_cache"]); -export const retentionPolicyActionSchema = z.enum(["keep", "delete_cache"]); -export const retentionPolicySchema = z.object({ - action: retentionPolicyActionSchema, - deleteOnlyAfterUploaded: z.boolean(), - enabled: z.boolean(), - id: z.string().min(1), - maxAgeDays: z.number().int().positive().max(3650).nullable(), - maxBytes: z.number().int().positive().max(1_000_000_000_000_000).nullable(), - minFreeDiskPercent: z.number().int().min(0).max(95).nullable(), - name: z.string().min(1), - preserveTagged: z.boolean(), - scope: retentionPolicyScopeSchema, - updatedAt: isoDateTimeSchema, -}); -export const retentionPolicyInputSchema = z.object({ - action: retentionPolicyActionSchema.default("keep"), - deleteOnlyAfterUploaded: z.boolean().default(true), - enabled: z.boolean().default(true), - id: z.string().trim().min(1).max(160).optional(), - maxAgeDays: z.number().int().positive().max(3650).nullable().default(null), - maxBytes: z.number().int().positive().max(1_000_000_000_000_000).nullable().default(null), - minFreeDiskPercent: z.number().int().min(0).max(95).nullable().default(null), - name: z.string().trim().min(1).max(160), - preserveTagged: z.boolean().default(true), - scope: retentionPolicyScopeSchema.default("controller_cache"), -}); -export const retentionPolicyUpdateSchema = z - .object({ - action: retentionPolicyActionSchema.optional(), - deleteOnlyAfterUploaded: z.boolean().optional(), - enabled: z.boolean().optional(), - maxAgeDays: z.number().int().positive().max(3650).nullable().optional(), - maxBytes: z.number().int().positive().max(1_000_000_000_000_000).nullable().optional(), - minFreeDiskPercent: z.number().int().min(0).max(95).nullable().optional(), - name: z.string().trim().min(1).max(160).optional(), - preserveTagged: z.boolean().optional(), - scope: retentionPolicyScopeSchema.optional(), - }) - .refine( - (value) => Object.keys(value).length > 0, - "At least one retention policy field is required", - ); - -export const healthEventSchema = z.object({ - acknowledgedAt: isoDateTimeSchema.nullable(), - acknowledgedBy: z.string().optional(), - details: z.record(z.string(), z.unknown()), - id: z.string().min(1), - nodeId: z.string().min(1).optional(), - openedAt: isoDateTimeSchema, - recordingId: z.string().optional(), - resolvedAt: isoDateTimeSchema.nullable(), - resolvedBy: z.string().optional(), - scheduleId: z.string().optional(), - severity: healthSeveritySchema, - status: healthEventStatusSchema, - suppressedAt: isoDateTimeSchema.nullable(), - suppressedBy: z.string().optional(), - suppressedUntil: isoDateTimeSchema.nullable(), - type: z.string().min(1), -}); - -export const defaultStubUploadPolicy = { - deleteCacheAfterUpload: false, - enabled: true, - id: "upload-policy-stub", - maxAttempts: 5, - name: "Stub Upload Queue", - trigger: "manual", - updatedAt: "1970-01-01T00:00:00.000Z", -} satisfies UploadPolicy; - -export const defaultKeepControllerCacheRetentionPolicy = { - action: "keep", - deleteOnlyAfterUploaded: true, - enabled: true, - id: "retention-keep-controller-cache", - maxAgeDays: null, - maxBytes: null, - minFreeDiskPercent: null, - name: "Keep Controller Cache", - preserveTagged: true, - scope: "controller_cache", - updatedAt: "1970-01-01T00:00:00.000Z", -} satisfies RetentionPolicy; - -export type AudioChannel = z.infer; -export type ChannelMapAssignmentHistory = z.infer; -export type ChannelMapEntry = z.infer; -export type ChannelMapTemplate = z.infer; -export type ChannelMapTemplateAssignment = z.infer; -export type ChannelMapTemplateAssignmentInput = z.infer< - typeof channelMapTemplateAssignmentInputSchema ->; -export type ChannelMapTemplateAssignmentBulkInput = z.infer< - typeof channelMapTemplateAssignmentBulkInputSchema ->; -export type ChannelMapAssignmentPlan = z.infer; -export type ChannelMapAssignmentPlanInput = z.infer; -export type ChannelMapTemplateAssignmentRollbackInput = z.infer< - typeof channelMapTemplateAssignmentRollbackInputSchema ->; -export type ChannelMapTemplateInput = z.infer; -export type ChannelMapTemplateUpdate = z.infer; -export type AuditActorType = z.infer; -export type AuditEvent = z.infer; -export type AuditOutcome = z.infer; -export type AudioInterface = z.infer; -export type AccessPolicy = z.infer; -export type AccessPolicyEffect = z.infer; -export type AccessPolicyInput = z.infer; -export type AccessPolicySubjectType = z.infer; -export type AudioLevel = z.infer; -export type AudioQuality = z.infer; -export type CurrentUser = z.infer; -export type HealthEvent = z.infer; -export type HealthEventStatus = z.infer; -export type HealthSeverity = z.infer; -export type MeterFrame = z.infer; -export type NodeStatus = z.infer; -export type NodeAudioCommandDefaults = z.infer; -export type NodeRecordingCapacity = z.infer; -export type NodeRuntime = z.infer; -export type RecorderNode = z.infer; -export type ControllerSettings = z.infer; -export type ControllerSettingsUpdate = z.infer; -export type WeekStartDay = z.infer; -export type RecordingJob = z.infer; -export type RecordingJobChannelMap = z.infer; -export type RecordingJobStatus = z.infer; -export type RecordingSummary = z.infer; -export type RecordingWaveformPreview = NonNullable; -export type RetentionPolicy = z.infer; -export type RetentionPolicyAction = z.infer; -export type RetentionPolicyInput = z.infer; -export type RetentionPolicyScope = z.infer; -export type RetentionPolicyUpdate = z.infer; -export type ResourceGrant = z.infer; -export type ScheduleCalendarOccurrence = z.infer; -export type ScheduleCalendarResponse = z.infer; -export type ScheduleDayOfWeek = z.infer; -export type ScheduleException = z.infer; -export type ScheduleInput = z.infer; -export type ScheduleOccurrencePreview = z.infer; -export type ScheduleRecurrenceMode = z.infer; -export type ScheduleRecurrence = z.infer; -export type ScheduleSummary = z.infer; -export type ScheduleUpdate = z.infer; -export type UploadProvider = z.infer; -export type UploadPolicy = z.infer; -export type UploadPolicyInput = z.infer; -export type UploadPolicyTrigger = z.infer; -export type UploadPolicyUpdate = z.infer; -export type UploadChecksumVerification = z.infer; -export type UploadQueueItem = z.infer; -export type UploadQueueRunItem = z.infer; -export type UploadQueueRunSummary = z.infer; -export type UploadQueueStatus = z.infer; -export type UploadRunnerStatus = z.infer; diff --git a/packages/shared/src/nodes.ts b/packages/shared/src/nodes.ts new file mode 100644 index 0000000..28473a7 --- /dev/null +++ b/packages/shared/src/nodes.ts @@ -0,0 +1,150 @@ +import { z } from "zod"; + +import { audioCaptureBackendSchema, dbfsSchema, isoDateTimeSchema } from "./base.js"; + +// "provisioning" is an enrolled node that has never made contact (no bootstrap, +// no heartbeat). It is excluded from liveness/offline monitoring until its first +// heartbeat flips it to a live status — see node-liveness / watchdog-node-liveness. +export const nodeStatusSchema = z.enum([ + "provisioning", + "online", + "offline", + "degraded", + "recording", + "alerting", +]); +export type NodeStatus = z.infer; + +// A node is "reachable" (in contact and reporting) when its status is online, +// recording, degraded, or alerting. "offline" (heartbeat gone stale) and +// "provisioning" (enrolled but never contacted) are NOT reachable. Shared by the +// /metrics `rakkr_node_online` gauge and the dashboard active-node count so the +// two cannot diverge on how a never-contacted provisioning node is treated +// (a naive `status !== "offline"` counts provisioning as online — see audit N1/N2). +export function isNodeReachable(status: NodeStatus): boolean { + return ( + status === "online" || status === "recording" || status === "degraded" || status === "alerting" + ); +} + +export const audioChannelSchema = z.object({ + alias: z.string().min(1), + index: z.number().int().positive(), + // Room that owns this channel. Room ownership is per-channel: any set of a + // node's channels can belong to a room, and each channel belongs to at most one + // room. Absent means the channel inherits the node default room. `roomName` is a + // denormalized display copy resolved by the controller. + roomId: z.string().min(1).optional(), + roomName: z.string().min(1).optional(), +}); + +export const audioInterfaceSchema = z.object({ + absent: z.boolean().optional(), // flagged absent by inventory reconcile; see node-store + alias: z.string().min(1), + backend: z.enum(["alsa", "jack", "pipewire", "unknown"]), + channelCount: z.number().int().nonnegative(), + channels: z.array(audioChannelSchema), + hardwarePath: z.string().min(1).optional(), + id: z.string().min(1), + sampleRates: z.array(z.number().int().positive()), + serialNumber: z.string().min(1).optional(), + systemName: z.string().min(1), + systemRef: z.string().min(1).optional(), +}); +export const nodeRuntimeSchema = z.object({ + architecture: z.string().min(1).optional(), + audioBackends: z.array(z.enum(["alsa", "jack", "pipewire", "unknown"])).default([]), + kernelRelease: z.string().min(1).optional(), + osName: z.string().min(1).optional(), + uptimeSeconds: z.number().int().nonnegative().optional(), +}); +export const defaultNodeRecordingCapacity = { maxConcurrentRecordings: 1 } as const; +export const nodeRecordingCapacitySchema = z.object({ + maxConcurrentRecordings: z.number().int().positive().max(128), +}); +export const nodeAudioCommandDefaultsSchema = z.object({ + captureArgsTemplate: z.string().trim().min(1).max(1000).optional(), + captureBackend: audioCaptureBackendSchema.optional(), + captureChannels: z.number().int().positive().max(256).optional(), + captureCommand: z.string().trim().min(1).max(255).optional(), + captureDevice: z.string().trim().min(1).max(255).optional(), + captureFormat: z.string().trim().min(1).max(80).optional(), + captureSampleRate: z.number().int().positive().max(384_000).optional(), + meterArgsTemplate: z.string().trim().min(1).max(1000).optional(), +}); + +export const audioQualitySchema = z.object({ + channelCorrelation: z + .object({ + peerChannelIndex: z.number().int().positive(), + phase: z.enum(["same", "inverted"]), + score: z.number().min(-1).max(1), + }) + .optional(), + broadbandNoiseScore: z.number().min(0).max(1).optional(), + crestFactorDb: z.number().min(0).max(80), + estimatedSnrDb: z.number().min(0).max(80).optional(), + humScore: z.number().min(0).max(1).optional(), + intelligibilityScore: z.number().min(0).max(1).optional(), + noiseScore: z.number().min(0).max(1), + speechLike: z.boolean(), + speechScore: z.number().min(0).max(1), + staticScore: z.number().min(0).max(1).optional(), + zeroCrossingRate: z.number().min(0).max(1), +}); +export const recorderNodeSchema = z.object({ + agentVersion: z.string().min(1), + alias: z.string().min(1), + hostname: z.string().min(1), + id: z.string().min(1), + interfaces: z.array(audioInterfaceSchema), + ipAddresses: z.array(z.string().min(1)), + lastSeenAt: isoDateTimeSchema, + location: z.object({ + building: z.string().optional(), + floor: z.string().optional(), + room: z.string().min(1), + site: z.string().min(1), + }), + // Node default room. Room ownership is per-channel (see audioChannelSchema); + // this is the fallback room for channels with no room of their own, and the + // display fallback for a node whose channels are all in one room. `location` + // above is retained for physical-install display. + roomId: z.string().min(1).optional(), + notes: z.string().optional(), + audioDefaults: nodeAudioCommandDefaultsSchema.optional(), + recordingCapacity: nodeRecordingCapacitySchema.optional(), + runtime: nodeRuntimeSchema.optional(), + status: nodeStatusSchema, + tags: z.array(z.string().min(1)), +}); + +export const audioLevelSchema = z.object({ + channelIndex: z.number().int().positive(), + clipping: z.boolean(), + label: z.string().min(1), + peakDbfs: dbfsSchema, + quality: audioQualitySchema.optional(), + rmsDbfs: dbfsSchema, +}); + +export const meterFrameSchema = z.object({ + capturedAt: isoDateTimeSchema, + interfaceId: z.string().min(1), + // Real interfaces have at most a few dozen channels (X32 = 32). Cap the array + // so a malformed/hostile node frame cannot wedge the watchdog's + // `Math.max(...levels)` spread with a RangeError — which would poison the + // stored `latest` frame and silently disable that node's watchdog. + levels: z.array(audioLevelSchema).max(512), + nodeId: z.string().min(1), +}); + +export type AudioChannel = z.infer; +export type AudioInterface = z.infer; +export type AudioLevel = z.infer; +export type AudioQuality = z.infer; +export type MeterFrame = z.infer; +export type NodeAudioCommandDefaults = z.infer; +export type NodeRecordingCapacity = z.infer; +export type NodeRuntime = z.infer; +export type RecorderNode = z.infer; diff --git a/packages/shared/src/rbac.ts b/packages/shared/src/rbac.ts new file mode 100644 index 0000000..2599c09 --- /dev/null +++ b/packages/shared/src/rbac.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { accessGroupSchema } from "./access-groups.js"; +import { isoDateTimeSchema } from "./base.js"; + +// Role/permission catalog and the derived allow decisions. This is the RBAC +// source of truth shared by the controller API and the operator console. +export const permissions = [ + "audit:read", + "auth:manage", + "health:acknowledge", + "health:read", + "listen:monitor", + "metrics:read", + "node:control", + "node:manage", + "node:read", + "recording:control", + "recording:create", + "recording:delete", + "recording:download", + "recording:edit", + "recording:playback", + "recording:read", + "schedule:manage", + "schedule:read", + "settings:manage", + "settings:read", + "switcher:manage", + "switcher:map", + "switcher:read", + "system:admin", +] as const; + +export const roles = ["owner", "admin", "operator", "viewer", "auditor"] as const; +export type Permission = (typeof permissions)[number]; +export type Role = (typeof roles)[number]; +export const permissionSchema = z.enum(permissions); +export const roleSchema = z.enum(roles); +export const accessPolicyEffectSchema = z.enum(["allow", "deny"]); +export const accessPolicySubjectTypeSchema = z.enum(["user", "group", "everyone"]); +export const resourceGrantSchema = z.object({ + resourceId: z.string().min(1), + resourceType: z.string().min(1), +}); +export const accessPolicySchema = z.object({ + effect: accessPolicyEffectSchema, + id: z.string().min(1), + reason: z.string().optional(), + resourceId: z.string().min(1), + resourceType: z.string().min(1), + subjectId: z.string().optional(), + subjectType: accessPolicySubjectTypeSchema, +}); +export const accessPolicyInputSchema = accessPolicySchema.omit({ id: true }); +export const rolePermissions: Record = { + admin: permissions.filter((permission) => permission !== "system:admin"), + auditor: ["audit:read", "health:read", "metrics:read", "recording:read"], + operator: [ + "health:acknowledge", + "health:read", + "listen:monitor", + "metrics:read", + "node:control", + "node:read", + "recording:control", + "recording:create", + "recording:download", + "recording:edit", + "recording:playback", + "recording:read", + "schedule:manage", + "schedule:read", + "settings:read", + "switcher:map", + "switcher:read", + ], + owner: permissions, + viewer: [ + "health:read", + "metrics:read", + "node:read", + "recording:download", + "recording:playback", + "recording:read", + "schedule:read", + "settings:read", + "switcher:read", + ], +}; + +export function hasPermission(role: Role, permission: Permission) { + return rolePermissions[role].includes(permission); +} + +export function hasAnyPermission(role: Role, required: Permission[]) { + return required.some((permission) => hasPermission(role, permission)); +} + +export const currentUserSchema = z.object({ + disabledAt: isoDateTimeSchema.optional(), + email: z.string().email(), + groups: z.array(accessGroupSchema), + id: z.string().min(1), + name: z.string().min(1), + permissions: z.array(permissionSchema), + provider: z.enum(["local", "oidc"]), + resourceGrants: z.array(resourceGrantSchema), + roles: z.array(roleSchema), +}); + +export type AccessPolicy = z.infer; +export type AccessPolicyEffect = z.infer; +export type AccessPolicyInput = z.infer; +export type AccessPolicySubjectType = z.infer; +export type CurrentUser = z.infer; +export type ResourceGrant = z.infer; diff --git a/packages/shared/src/recordings.ts b/packages/shared/src/recordings.ts new file mode 100644 index 0000000..328f717 --- /dev/null +++ b/packages/shared/src/recordings.ts @@ -0,0 +1,139 @@ +import { z } from "zod"; + +import { audioCaptureBackendSchema, isoDateTimeSchema } from "./base.js"; +import { channelMapEntrySchema, templateAssignmentTargetSchema } from "./channel-maps.js"; +import { captureChannelSelectionSchema, channelModeSchema } from "./channels.js"; +import { recordingEnhancementSchema } from "./enhancement.js"; +import { recordingChunkSchema } from "./recording-chunks.js"; + +export const recordingSourceSchema = z.enum(["ad_hoc", "schedule"]); +export const recordingStatusSchema = z.enum([ + "queued", + "recording", + "completed", + "failed", + "cached", + "uploaded", + "partial", +]); +export const recordingJobStatusSchema = z.enum([ + "queued", + "running", + "stop_requested", + "cancelled", + "completed", + "failed", +]); + +export const recordingSummarySchema = z.object({ + cached: z.boolean(), + cachePath: z.string().min(1).optional(), + checksum: z.string().min(1).optional(), + chunks: z.array(recordingChunkSchema).optional(), + chunkSeconds: z.number().int().positive().optional(), + chunkTotal: z.number().int().positive().optional(), + durationSeconds: z.number().int().nonnegative(), + enhancedCachePath: z.string().min(1).optional(), + rawCachePath: z.string().min(1).optional(), + folder: z.string().min(1), + healthStatus: z.enum(["healthy", "warning", "critical", "unknown"]), + id: z.string().min(1), + name: z.string().min(1), + nodeId: z.string().min(1).optional(), + notes: z.string().max(2000).optional(), + recordedAt: isoDateTimeSchema, + recordingProfileId: z.string().min(1).optional(), + retentionPolicyId: z.string().min(1).optional(), + // Room that owns this recording, captured at create time from the selected + // channels' room. Persisted, not derived, so a later channel reassignment does + // not retroactively move a completed recording. + roomId: z.string().min(1).optional(), + scheduleId: z.string().min(1).optional(), + source: recordingSourceSchema, + status: recordingStatusSchema, + tags: z.array(z.string().min(1)), + transcriptSnippets: z.array(z.string().trim().min(1).max(500)).max(20).optional(), + trackGroupId: z.string().min(1).optional(), + trackIndex: z.number().int().positive().optional(), + trackTotal: z.number().int().positive().optional(), + uploadPolicyIds: z.array(z.string().min(1)).optional(), + watchdogPolicyId: z.string().min(1).optional(), + waveformPreview: z + .object({ + channelCount: z.number().int().positive(), + generatedAt: isoDateTimeSchema, + peaks: z.array(z.number().min(0).max(1)).min(1).max(256), + sampleCount: z.number().int().positive(), + sampleRate: z.number().int().positive(), + source: z.enum(["ffmpeg_decoded_peak", "wav_s16le_peak"]), + }) + .optional(), +}); +export const recordingJobChannelMapSchema = z.object({ + assignmentId: z.string().min(1), + channelMode: channelModeSchema, + entries: z.array(channelMapEntrySchema).min(1).max(128), + sourceChannels: z.number().int().positive(), + targetId: z.string().min(1), + targetType: templateAssignmentTargetSchema, + templateId: z.string().min(1), + templateName: z.string().min(1), +}); +export const recordingJobSchema = z.object({ + claimedBy: z.string().min(1).optional(), + command: z.object({ + captureBackend: audioCaptureBackendSchema.optional(), + captureChannels: z.number().int().positive(), + // Resolved 1-based source channels this job owns on the interface. Absent = + // whole interface (legacy). Drives both the channel map and the controller's + // per-channel conflict detection. + captureChannelSelection: captureChannelSelectionSchema.optional(), + captureDevice: z.string().min(1), + captureFormat: z.string().min(1), + // Jobs sharing an interface + capture window carry the same group id so the + // agent can capture the device once and split it into per-job renditions. + captureGroupId: z.string().min(1).optional(), + captureInterfaceId: z.string().min(1).optional(), + captureSampleRate: z.number().int().positive(), + channelMap: recordingJobChannelMapSchema.optional(), + chunkSeconds: z.number().int().positive().optional(), + durationSeconds: z.number().int().positive(), + enhancement: recordingEnhancementSchema.optional(), + outputBitrateKbps: z.number().int().positive().optional(), + outputCodec: z.enum(["mp3", "flac", "wav"]).optional(), + outputFileName: z.string().min(1), + outputVbr: z.boolean().optional(), + recorderCacheRetention: z + .object({ + deleteAfterUpload: z.boolean(), + maxAgeDays: z.number().int().positive().nullable().optional(), + maxBytes: z.number().int().positive().nullable().optional(), + minFreeDiskPercent: z.number().int().min(0).max(95).nullable().optional(), + policyId: z.string().min(1), + }) + .optional(), + trackGroupId: z.string().min(1).optional(), + trackIndex: z.number().int().positive().optional(), + trackTotal: z.number().int().positive().optional(), + type: z.literal("alsa_capture"), + }), + chunks: z.array(recordingChunkSchema).optional(), + chunkTotal: z.number().int().positive().optional(), + completedAt: isoDateTimeSchema.optional(), + createdAt: isoDateTimeSchema, + failureReason: z.string().optional(), + id: z.string().min(1), + lastHeartbeatAt: isoDateTimeSchema.optional(), + leaseExpiresAt: isoDateTimeSchema.optional(), + nodeId: z.string().min(1), + recordingId: z.string().min(1), + startedAt: isoDateTimeSchema.optional(), + status: recordingJobStatusSchema, + stopRequestedAt: isoDateTimeSchema.optional(), +}); + +export type RecordingJob = z.infer; +export type RecordingJobChannelMap = z.infer; +export type RecordingJobStatus = z.infer; +export type RecordingSummary = z.infer; +export type RecordingWaveformPreview = NonNullable; diff --git a/packages/shared/src/retention.ts b/packages/shared/src/retention.ts new file mode 100644 index 0000000..535e876 --- /dev/null +++ b/packages/shared/src/retention.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import { isoDateTimeSchema } from "./base.js"; + +export const retentionPolicyScopeSchema = z.enum(["controller_cache", "recorder_cache"]); +export const retentionPolicyActionSchema = z.enum(["keep", "delete_cache"]); +export const retentionPolicySchema = z.object({ + action: retentionPolicyActionSchema, + deleteOnlyAfterUploaded: z.boolean(), + enabled: z.boolean(), + id: z.string().min(1), + maxAgeDays: z.number().int().positive().max(3650).nullable(), + maxBytes: z.number().int().positive().max(1_000_000_000_000_000).nullable(), + minFreeDiskPercent: z.number().int().min(0).max(95).nullable(), + name: z.string().min(1), + preserveTagged: z.boolean(), + scope: retentionPolicyScopeSchema, + updatedAt: isoDateTimeSchema, +}); +export const retentionPolicyInputSchema = z.object({ + action: retentionPolicyActionSchema.default("keep"), + deleteOnlyAfterUploaded: z.boolean().default(true), + enabled: z.boolean().default(true), + id: z.string().trim().min(1).max(160).optional(), + maxAgeDays: z.number().int().positive().max(3650).nullable().default(null), + maxBytes: z.number().int().positive().max(1_000_000_000_000_000).nullable().default(null), + minFreeDiskPercent: z.number().int().min(0).max(95).nullable().default(null), + name: z.string().trim().min(1).max(160), + preserveTagged: z.boolean().default(true), + scope: retentionPolicyScopeSchema.default("controller_cache"), +}); +export const retentionPolicyUpdateSchema = z + .object({ + action: retentionPolicyActionSchema.optional(), + deleteOnlyAfterUploaded: z.boolean().optional(), + enabled: z.boolean().optional(), + maxAgeDays: z.number().int().positive().max(3650).nullable().optional(), + maxBytes: z.number().int().positive().max(1_000_000_000_000_000).nullable().optional(), + minFreeDiskPercent: z.number().int().min(0).max(95).nullable().optional(), + name: z.string().trim().min(1).max(160).optional(), + preserveTagged: z.boolean().optional(), + scope: retentionPolicyScopeSchema.optional(), + }) + .refine( + (value) => Object.keys(value).length > 0, + "At least one retention policy field is required", + ); + +export const defaultKeepControllerCacheRetentionPolicy = { + action: "keep", + deleteOnlyAfterUploaded: true, + enabled: true, + id: "retention-keep-controller-cache", + maxAgeDays: null, + maxBytes: null, + minFreeDiskPercent: null, + name: "Keep Controller Cache", + preserveTagged: true, + scope: "controller_cache", + updatedAt: "1970-01-01T00:00:00.000Z", +} satisfies RetentionPolicy; + +export type RetentionPolicy = z.infer; +export type RetentionPolicyAction = z.infer; +export type RetentionPolicyInput = z.infer; +export type RetentionPolicyScope = z.infer; +export type RetentionPolicyUpdate = z.infer; diff --git a/packages/shared/src/room-capabilities.ts b/packages/shared/src/room-capabilities.ts index c6266f6..fdd1919 100644 --- a/packages/shared/src/room-capabilities.ts +++ b/packages/shared/src/room-capabilities.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import type { Permission } from "./index.js"; +import type { Permission } from "./rbac.js"; // The independently-grantable per-action room capabilities. A room roster entry // carries any subset of these; effective access is the union across a subject's diff --git a/packages/shared/src/schedules.ts b/packages/shared/src/schedules.ts new file mode 100644 index 0000000..0c9ec0e --- /dev/null +++ b/packages/shared/src/schedules.ts @@ -0,0 +1,209 @@ +import { z } from "zod"; + +import { accessGroupIdSchema } from "./access-groups.js"; +import { + audioCaptureBackendSchema, + ianaTimeZoneSchema, + isoDateSchema, + isoDateTimeSchema, + timeOfDaySchema, +} from "./base.js"; +import { captureChannelSelectionSchema, channelModeSchema } from "./channels.js"; + +export const scheduleDayOfWeekSchema = z.enum([ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +]); +export const scheduleExceptionSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("skip"), + date: isoDateSchema, + reason: z.string().trim().max(240).optional(), + }), + z + .object({ + action: z.literal("pause"), + endDate: isoDateSchema, + reason: z.string().trim().max(240).optional(), + startDate: isoDateSchema, + }) + .refine((value) => value.startDate <= value.endDate, "Pause start must be before end"), +]); +const scheduleRecurrenceOptions = { + exceptions: z.array(scheduleExceptionSchema).max(366).optional(), + startEarlySeconds: z.number().int().nonnegative().max(86_400).optional(), + stopLateSeconds: z.number().int().nonnegative().max(86_400).optional(), +}; +export const scheduleRecurrenceSchema = z.discriminatedUnion("mode", [ + z.object({ + mode: z.literal("manual"), + ...scheduleRecurrenceOptions, + }), + z.object({ + // Optional fixed recording length (seconds) for a single-fire schedule. + // Set when a timed recurring occurrence is moved into a one-off so the + // moved recording keeps its original duration; absent = open-ended. + durationSeconds: z.number().int().positive().max(2_678_400).optional(), + mode: z.literal("once"), + startsAt: isoDateTimeSchema, + ...scheduleRecurrenceOptions, + }), + z.object({ + endTime: timeOfDaySchema, + interval: z.number().int().positive(), + mode: z.literal("daily"), + startTime: timeOfDaySchema, + ...scheduleRecurrenceOptions, + }), + z.object({ + daysOfWeek: z.array(scheduleDayOfWeekSchema).min(1).max(7), + endTime: timeOfDaySchema, + interval: z.number().int().positive(), + mode: z.literal("weekly"), + startTime: timeOfDaySchema, + ...scheduleRecurrenceOptions, + }), + z.object({ + dayOfMonth: z.number().int().min(1).max(31), + endTime: timeOfDaySchema, + interval: z.number().int().positive(), + mode: z.literal("monthly"), + startTime: timeOfDaySchema, + ...scheduleRecurrenceOptions, + }), + z.object({ + mode: z.literal("always_on"), + ...scheduleRecurrenceOptions, + }), +]); + +export const scheduleSummarySchema = z.object({ + assignedGroupIds: z.array(accessGroupIdSchema).default([]), + assignedUserIds: z.array(z.string().trim().min(1).max(160)).default([]), + captureBackend: audioCaptureBackendSchema.optional(), + captureChannelSelection: captureChannelSelectionSchema.optional(), + captureInterfaceId: z.string().min(1).optional(), + channelMode: channelModeSchema.optional(), + enabled: z.boolean(), + folderTemplate: z.string().min(1), + id: z.string().min(1), + name: z.string().min(1), + nextRunAt: isoDateTimeSchema.optional(), + nodeId: z.string().min(1), + recurrence: scheduleRecurrenceSchema.default({ mode: "manual" }), + recordingProfileId: z.string().min(1), + retentionPolicyId: z.string().min(1).default("retention-keep-controller-cache"), + room: z.string().min(1), + roomId: z.string().min(1).optional(), + tags: z.array(z.string().min(1)), + timezone: z.string().min(1), + titleTemplate: z.string().min(1), + uploadPolicyIds: z.array(z.string().min(1)).default([]), + watchdogPolicyId: z.string().min(1), +}); +export const scheduleInputSchema = z.object({ + assignedGroupIds: z.array(accessGroupIdSchema).max(128).default([]), + assignedUserIds: z.array(z.string().trim().min(1).max(160)).max(256).default([]), + captureBackend: audioCaptureBackendSchema.nullable().optional(), + captureChannelSelection: captureChannelSelectionSchema.nullable().optional(), + captureInterfaceId: z.string().trim().min(1).max(160).nullable().optional(), + channelMode: channelModeSchema.nullable().optional(), + enabled: z.boolean().default(true), + folderTemplate: z.string().trim().min(1).max(500), + id: z.string().trim().min(1).max(160).optional(), + name: z.string().trim().min(1).max(160), + nextRunAt: isoDateTimeSchema.optional(), + nodeId: z.string().trim().min(1).max(160), + recurrence: scheduleRecurrenceSchema.optional(), + recordingProfileId: z.string().trim().min(1).max(160), + retentionPolicyId: z.string().trim().min(1).max(160).default("retention-keep-controller-cache"), + room: z.string().trim().min(1).max(160), + roomId: z.string().trim().min(1).max(160).optional(), + tags: z.array(z.string().trim().min(1).max(80)).max(64).default([]), + timezone: ianaTimeZoneSchema, + titleTemplate: z.string().trim().min(1).max(500), + // Capped + deduped on write: each recording fans out to one upload queue item + // per id, so an uncapped list multiplies queue work per recording (audit R3-4). + uploadPolicyIds: z.array(z.string().trim().min(1).max(160)).max(32).default([]), + watchdogPolicyId: z.string().trim().min(1).max(160), +}); +export const scheduleUpdateSchema = z + .object({ + assignedGroupIds: z.array(accessGroupIdSchema).max(128).optional(), + assignedUserIds: z.array(z.string().trim().min(1).max(160)).max(256).optional(), + captureBackend: audioCaptureBackendSchema.nullable().optional(), + captureChannelSelection: captureChannelSelectionSchema.nullable().optional(), + captureInterfaceId: z.string().trim().min(1).max(160).nullable().optional(), + channelMode: channelModeSchema.nullable().optional(), + enabled: z.boolean().optional(), + folderTemplate: z.string().trim().min(1).max(500).optional(), + name: z.string().trim().min(1).max(160).optional(), + nextRunAt: isoDateTimeSchema.optional(), + nodeId: z.string().trim().min(1).max(160).optional(), + recurrence: scheduleRecurrenceSchema.optional(), + recordingProfileId: z.string().trim().min(1).max(160).optional(), + retentionPolicyId: z.string().trim().min(1).max(160).optional(), + room: z.string().trim().min(1).max(160).optional(), + roomId: z.string().trim().min(1).max(160).optional(), + tags: z.array(z.string().trim().min(1).max(80)).max(64).optional(), + timezone: ianaTimeZoneSchema.optional(), + titleTemplate: z.string().trim().min(1).max(500).optional(), + uploadPolicyIds: z.array(z.string().trim().min(1).max(160)).max(32).optional(), + watchdogPolicyId: z.string().trim().min(1).max(160).optional(), + }) + .refine((value) => Object.keys(value).length > 0, "At least one schedule field is required"); +export const scheduleOccurrencePreviewSchema = z.object({ + recordingEndAt: isoDateTimeSchema.optional(), + recordingStartAt: isoDateTimeSchema, + scheduledStartAt: isoDateTimeSchema.optional(), +}); +export const scheduleRecurrenceModeSchema = z.enum([ + "manual", + "once", + "daily", + "weekly", + "monthly", + "always_on", +]); +// A single occurrence event returned by the calendar endpoint. Extends the +// occurrence preview with the owning schedule's identity so the calendar can +// render and route interactions (recurrenceMode picks the drag behavior: +// once -> move in place, recurring -> split a single instance). +export const scheduleCalendarOccurrenceSchema = scheduleOccurrencePreviewSchema.extend({ + enabled: z.boolean(), + nodeId: z.string().min(1), + recurrenceMode: scheduleRecurrenceModeSchema, + room: z.string().min(1), + scheduleId: z.string().min(1), + scheduleName: z.string().min(1), + // The schedule's IANA timezone, so the calendar groups each chip on the + // SCHEDULE-local day (matching the backend skip/occurrence day) rather than the + // viewer's browser-local day when the two differ (see calendar-grid groupByLocalDay). + timezone: z.string().min(1).max(80), +}); +export const scheduleCalendarResponseSchema = z.object({ + data: z.array(scheduleCalendarOccurrenceSchema), + meta: z.object({ + end: isoDateTimeSchema, + occurrenceCount: z.number().int().nonnegative(), + scheduleCount: z.number().int().nonnegative(), + start: isoDateTimeSchema, + truncated: z.boolean(), + }), +}); + +export type ScheduleCalendarOccurrence = z.infer; +export type ScheduleCalendarResponse = z.infer; +export type ScheduleDayOfWeek = z.infer; +export type ScheduleException = z.infer; +export type ScheduleInput = z.infer; +export type ScheduleOccurrencePreview = z.infer; +export type ScheduleRecurrence = z.infer; +export type ScheduleRecurrenceMode = z.infer; +export type ScheduleSummary = z.infer; +export type ScheduleUpdate = z.infer; diff --git a/packages/shared/src/uploads.ts b/packages/shared/src/uploads.ts new file mode 100644 index 0000000..d664622 --- /dev/null +++ b/packages/shared/src/uploads.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { isoDateTimeSchema, uploadProviderSchema, uploadQueueStatusSchema } from "./base.js"; + +export const uploadQueueItemSchema = z.object({ + attemptCount: z.number().int().nonnegative(), + cachePath: z.string().min(1).optional(), + checksum: z.string().min(1).optional(), + // Set when this item uploads one recording chunk as its own object. NULL = + // legacy whole-recording item. + chunkId: z.string().min(1).optional(), + chunkIndex: z.number().int().positive().optional(), + createdAt: isoDateTimeSchema, + destinationId: z.string().min(1).optional(), + fileName: z.string().min(1).optional(), + id: z.string().min(1), + lastError: z.string().min(1).optional(), + maxAttempts: z.number().int().positive(), + nextAttemptAt: isoDateTimeSchema, + pathOverride: z.string().min(1).optional(), + provider: uploadProviderSchema, + recordingId: z.string().min(1), + status: uploadQueueStatusSchema, + target: z.string().min(1).optional(), + updatedAt: isoDateTimeSchema, + uploadPolicyId: z.string().min(1).optional(), +}); +export const uploadChecksumVerificationSchema = z.object({ + algorithm: z.literal("sha256"), + expected: z.string().min(1), + method: z.enum(["file_copy_sha256", "s3_checksum_sha256"]), + observed: z.string().min(1).optional(), + // `provider_declared` is the honest status for S3-compatible custom endpoints + // that may ignore the trailing ChecksumSHA256; only real AWS S3 validates it, + // which is `provider_validated`. `matched` is our own read-back byte compare. + status: z.enum(["matched", "provider_validated", "provider_declared"]), +}); +export const uploadQueueRunItemSchema = z.object({ + checksumVerification: uploadChecksumVerificationSchema.optional(), + itemId: z.string().min(1), + provider: uploadProviderSchema, + reason: z.string().min(1).optional(), + recordingId: z.string().min(1), + status: uploadQueueStatusSchema, +}); +export const uploadQueueRunSummarySchema = z.object({ + attempted: z.number().int().nonnegative(), + deferred: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + items: z.array(uploadQueueRunItemSchema), + succeeded: z.number().int().nonnegative(), +}); +export const uploadRunnerStatusSchema = z.object({ + batchSize: z.number().int().positive(), + intervalSeconds: z.number().int().positive(), + lastRunAt: isoDateTimeSchema.optional(), + lastSummary: uploadQueueRunSummarySchema.optional(), + running: z.boolean(), + started: z.boolean(), +}); + +export const uploadPolicyTriggerSchema = z.enum(["manual", "on_recording_cached"]); +export const uploadPolicySchema = z.object({ + deleteCacheAfterUpload: z.boolean().default(false), + destinationId: z.string().min(1).optional(), + enabled: z.boolean(), + id: z.string().min(1), + maxAttempts: z.number().int().positive().max(100), + name: z.string().min(1), + pathOverride: z.string().min(1).max(500).optional(), + trigger: uploadPolicyTriggerSchema, + updatedAt: isoDateTimeSchema, +}); +export const uploadPolicyInputSchema = z.object({ + deleteCacheAfterUpload: z.boolean().default(false), + // Optional in the shared shape (the store is an internal seeding primitive), + // but the operator create route requires it — every policy must target a real + // destination or its recordings reconcile to `partial` (audit H3-3). + destinationId: z.string().trim().min(1).max(160).optional(), + enabled: z.boolean().default(true), + id: z.string().trim().min(1).max(160).optional(), + maxAttempts: z.number().int().positive().max(100).default(5), + name: z.string().trim().min(1).max(160), + pathOverride: z.string().trim().min(1).max(500).optional(), + trigger: uploadPolicyTriggerSchema.default("manual"), +}); +export const uploadPolicyUpdateSchema = z + .object({ + deleteCacheAfterUpload: z.boolean().optional(), + destinationId: z.string().trim().min(1).max(160).optional(), + enabled: z.boolean().optional(), + maxAttempts: z.number().int().positive().max(100).optional(), + name: z.string().trim().min(1).max(160).optional(), + pathOverride: z.string().trim().min(1).max(500).optional(), + trigger: uploadPolicyTriggerSchema.optional(), + }) + .refine((value) => Object.keys(value).length > 0, "At least one upload policy field is required"); + +export const defaultStubUploadPolicy = { + deleteCacheAfterUpload: false, + enabled: true, + id: "upload-policy-stub", + maxAttempts: 5, + name: "Stub Upload Queue", + trigger: "manual", + updatedAt: "1970-01-01T00:00:00.000Z", +} satisfies UploadPolicy; + +export type UploadChecksumVerification = z.infer; +export type UploadPolicy = z.infer; +export type UploadPolicyInput = z.infer; +export type UploadPolicyTrigger = z.infer; +export type UploadPolicyUpdate = z.infer; +export type UploadQueueItem = z.infer; +export type UploadQueueRunItem = z.infer; +export type UploadQueueRunSummary = z.infer; +export type UploadRunnerStatus = z.infer; diff --git a/scripts/agent-fake-controller-smoke-health-meters.mjs b/scripts/agent-fake-controller-smoke-health-meters.mjs new file mode 100644 index 0000000..45ebbc2 --- /dev/null +++ b/scripts/agent-fake-controller-smoke-health-meters.mjs @@ -0,0 +1,431 @@ +import path from "node:path"; + +import { invariant, readJsonLines, waitFor } from "./agent-fake-controller-smoke-utils.mjs"; + +export async function runMeterXrunScenario({ + address, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, + xrunMeterCommand, +}) { + const stateFile = path.join(smokeRoot, "meter-xrun-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "meter-xrun-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ jobs: [], observed, scenario: { expectSuccess: true, name: "meter-xrun" } }); + const child = spawnDaemonAgent({ + address, + captureCommand: xrunMeterCommand, + extraAgentArgs: ["--meter-backend", "alsa"], + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.meter.xrun") && + observed.meterFrames >= 1 && + observed.monitorChunks.length >= 1, + 20_000, + () => + `meters=${observed.meterFrames} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + const healthLogEvents = await readJsonLines(healthLogFile); + const syncedEvent = observed.healthEvents.find((event) => event.type === "agent.meter.xrun"); + + invariant(syncedEvent?.severity === "warning", "meter xrun event was not warning"); + invariant( + syncedEvent?.details?.usingSyntheticFallback === true, + "meter xrun did not report synthetic fallback", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.meter.xrun"), + "meter xrun event was not written locally", + ); + setActiveScenario(undefined); +} + +export async function runMeterFrameSyncRecoveryScenario({ + address, + captureCommand, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "meter-frame-sync-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "meter-frame-sync-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { + expectSuccess: true, + meterFrameFailuresRemaining: 1, + name: "meter-frame-sync-recovery", + }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.meter_frame.sync_failed") && + observed.healthEvents.some((event) => event.type === "agent.meter_frame.sync_recovered") && + observed.meterFrameFailures === 1 && + observed.meterFrames >= 1, + 20_000, + () => + `meterFailures=${observed.meterFrameFailures} meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const healthLogEvents = await readJsonLines(healthLogFile); + const failedEvent = observed.healthEvents.find( + (event) => event.type === "agent.meter_frame.sync_failed", + ); + const recoveredEvent = observed.healthEvents.find( + (event) => event.type === "agent.meter_frame.sync_recovered", + ); + + invariant(failedEvent?.severity === "warning", "meter-frame sync failure was not warning"); + invariant( + String(failedEvent?.details?.error).includes("controller rejected meter frame with 503"), + "meter-frame sync failure did not preserve controller rejection", + ); + invariant( + failedEvent?.details?.interfaceId === "iface_default_capture", + "meter-frame sync failure did not preserve interface id", + ); + invariant( + failedEvent?.details?.channelCount === 2, + "meter-frame sync failure did not preserve channel count", + ); + invariant(recoveredEvent?.severity === "info", "meter-frame sync recovery was not info"); + invariant( + recoveredEvent?.details?.interfaceId === failedEvent.details.interfaceId, + "meter-frame sync recovery did not preserve interface id", + ); + invariant( + recoveredEvent?.details?.channelCount === failedEvent.details.channelCount, + "meter-frame sync recovery did not preserve channel count", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.meter_frame.sync_failed"), + "meter-frame sync failure event was not written locally", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.meter_frame.sync_recovered"), + "meter-frame sync recovery event was not written locally", + ); + setActiveScenario(undefined); +} + +export async function runMeterDeviceUnavailableScenario({ + address, + createObserved, + deviceUnavailableMeterCommand, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "meter-device-unavailable-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "meter-device-unavailable-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { expectSuccess: true, name: "meter-device-unavailable" }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand: deviceUnavailableMeterCommand, + extraAgentArgs: ["--meter-backend", "alsa"], + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.meter.device_unavailable") && + observed.meterFrames >= 1 && + observed.monitorChunks.length >= 1, + 20_000, + () => + `meters=${observed.meterFrames} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + const healthLogEvents = await readJsonLines(healthLogFile); + const syncedEvent = observed.healthEvents.find( + (event) => event.type === "agent.meter.device_unavailable", + ); + + invariant( + syncedEvent?.severity === "critical", + "meter device unavailable event was not critical", + ); + invariant( + syncedEvent?.details?.usingSyntheticFallback === true, + "meter device unavailable did not report synthetic fallback", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.meter.device_unavailable"), + "meter device unavailable event was not written locally", + ); + setActiveScenario(undefined); +} + +export async function runMeterCaptureFailedScenario({ + address, + captureFailedMeterCommand, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "meter-capture-failed-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "meter-capture-failed-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { expectSuccess: true, name: "meter-capture-failed" }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand: captureFailedMeterCommand, + extraAgentArgs: ["--meter-backend", "alsa"], + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.meter.capture_failed") && + observed.meterFrames >= 1 && + observed.monitorChunks.length >= 1, + 20_000, + () => + `meters=${observed.meterFrames} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + const healthLogEvents = await readJsonLines(healthLogFile); + const syncedEvent = observed.healthEvents.find( + (event) => event.type === "agent.meter.capture_failed", + ); + + invariant(syncedEvent?.severity === "warning", "meter capture failure was not warning"); + invariant( + syncedEvent?.details?.classification === "capture_failed", + "meter capture failure did not preserve generic classification", + ); + invariant( + syncedEvent?.details?.usingSyntheticFallback === true, + "meter capture failure did not report synthetic fallback", + ); + invariant( + !observed.healthEvents.some((event) => event.type === "agent.meter.xrun"), + "generic meter capture failure was misclassified as xrun", + ); + invariant( + !observed.healthEvents.some((event) => event.type === "agent.meter.device_unavailable"), + "generic meter capture failure was misclassified as device unavailable", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.meter.capture_failed"), + "meter capture failure event was not written locally", + ); + setActiveScenario(undefined); +} + +export async function runMeterRecoveryScenario({ + address, + createObserved, + recoveringMeterCommand, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "meter-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "meter-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { expectSuccess: true, name: "meter-recovery" }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand: recoveringMeterCommand, + extraAgentArgs: ["--meter-backend", "alsa"], + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.meter.xrun") && + observed.healthEvents.some((event) => event.type === "agent.meter.capture_recovered") && + observed.meterFrames >= 2, + 20_000, + () => + `meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + const healthLogEvents = await readJsonLines(healthLogFile); + const recoveryEvent = observed.healthEvents.find( + (event) => event.type === "agent.meter.capture_recovered", + ); + + invariant(recoveryEvent?.severity === "info", "meter recovery event was not info"); + invariant( + recoveryEvent?.details?.previousType === "agent.meter.xrun", + "meter recovery did not preserve previous xrun type", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.meter.capture_recovered"), + "meter recovery event was not written locally", + ); + setActiveScenario(undefined); +} + +export async function runMonitorChunkRecoveryScenario({ + address, + captureCommand, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "monitor-chunk-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "monitor-chunk-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { + expectSuccess: true, + monitorChunkFailuresRemaining: 1, + name: "monitor-chunk-recovery", + }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some( + (event) => event.type === "agent.listen_monitor.chunk_sync_failed", + ) && + observed.healthEvents.some( + (event) => event.type === "agent.listen_monitor.chunk_sync_recovered", + ) && + observed.monitorChunkFailures === 1 && + observed.monitorChunks.length >= 1, + 20_000, + () => + `monitorFailures=${observed.monitorChunkFailures} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const healthLogEvents = await readJsonLines(healthLogFile); + const failedEvent = observed.healthEvents.find( + (event) => event.type === "agent.listen_monitor.chunk_sync_failed", + ); + const recoveredEvent = observed.healthEvents.find( + (event) => event.type === "agent.listen_monitor.chunk_sync_recovered", + ); + const uploadedChunk = observed.monitorChunks.at(0); + + invariant(failedEvent?.severity === "warning", "monitor chunk failure was not warning"); + invariant( + String(failedEvent?.details?.error).includes("controller rejected monitor chunk with 503"), + "monitor chunk failure did not preserve controller rejection", + ); + invariant( + failedEvent?.details?.contentType === "audio/wav", + "monitor chunk failure did not preserve content type", + ); + invariant( + failedEvent?.details?.durationMs === 1000, + "monitor chunk failure did not preserve duration", + ); + invariant( + failedEvent?.details?.monitorBytes > 44, + "monitor chunk failure did not preserve monitor bytes", + ); + invariant( + failedEvent?.details?.interfaceId === "iface_default_capture", + "monitor chunk failure did not preserve interface id", + ); + invariant(recoveredEvent?.severity === "info", "monitor chunk recovery was not info"); + invariant( + recoveredEvent?.details?.contentType === "audio/wav", + "monitor chunk recovery did not preserve content type", + ); + invariant( + recoveredEvent?.details?.monitorBytes > 44, + "monitor chunk recovery did not preserve monitor bytes", + ); + invariant( + uploadedChunk?.contentType === "audio/wav", + "fake controller did not receive monitor chunk content type", + ); + invariant(uploadedChunk?.durationMs === "1000", "fake controller did not receive duration"); + invariant(uploadedChunk?.size > 44, "fake controller did not receive monitor bytes"); + invariant( + healthLogEvents.some((event) => event.type === "agent.listen_monitor.chunk_sync_failed"), + "monitor chunk failure event was not written locally", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.listen_monitor.chunk_sync_recovered"), + "monitor chunk recovery event was not written locally", + ); + setActiveScenario(undefined); +} diff --git a/scripts/agent-fake-controller-smoke-health-system.mjs b/scripts/agent-fake-controller-smoke-health-system.mjs new file mode 100644 index 0000000..4e82dcb --- /dev/null +++ b/scripts/agent-fake-controller-smoke-health-system.mjs @@ -0,0 +1,495 @@ +import path from "node:path"; + +import { invariant, readJsonLines, waitFor } from "./agent-fake-controller-smoke-utils.mjs"; + +export async function runSystemHealthScenario({ + address, + captureCommand, + createObserved, + fakeDfPath, + fakeLoadavgPath, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "system-health-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "system-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { + expectSuccess: true, + name: "system-health", + }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + extraAgentArgs: [ + "--system-health-df-command", + fakeDfCommandPath(fakeDfPath), + "--system-health-load-warning-per-core", + "0.01", + "--system-health-load-critical-per-core", + "1000", + "--system-health-loadavg-path", + fakeLoadavgPath, + ], + extraEnv: { + PATH: `${fakeDfPath}${path.delimiter}${process.env.PATH ?? ""}`, + }, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.system.disk_pressure") && + observed.healthEvents.some((event) => event.type === "agent.system.cpu_pressure") && + observed.healthEvents.some((event) => event.type === "agent.system.disk_recovered") && + observed.healthEvents.some((event) => event.type === "agent.system.cpu_recovered") && + observed.nodeHeartbeats >= 2 && + observed.inventoryReconciles >= 1 && + observed.meterFrames >= 2, + 20_000, + () => + `heartbeats=${observed.nodeHeartbeats} inventory=${observed.inventoryReconciles} meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + const healthLogEvents = await readJsonLines(healthLogFile); + const syncedEvent = observed.healthEvents.find( + (event) => event.type === "agent.system.disk_pressure", + ); + const cpuEvent = observed.healthEvents.find( + (event) => event.type === "agent.system.cpu_pressure", + ); + const diskRecoveryEvent = observed.healthEvents.find( + (event) => event.type === "agent.system.disk_recovered", + ); + const cpuRecoveryEvent = observed.healthEvents.find( + (event) => event.type === "agent.system.cpu_recovered", + ); + const localEvent = healthLogEvents.find((event) => event.type === "agent.system.disk_pressure"); + const localCpuEvent = healthLogEvents.find( + (event) => event.type === "agent.system.cpu_pressure", + ); + const localDiskRecoveryEvent = healthLogEvents.find( + (event) => event.type === "agent.system.disk_recovered", + ); + const localCpuRecoveryEvent = healthLogEvents.find( + (event) => event.type === "agent.system.cpu_recovered", + ); + + invariant(syncedEvent?.severity === "warning", "system health disk event was not warning"); + invariant( + syncedEvent?.details?.usedPercent === 90, + "system health disk event did not use fake df pressure", + ); + invariant(cpuEvent?.severity === "warning", "system health CPU event was not warning"); + invariant( + cpuEvent?.details?.loadAverageOneMinute === 12.5, + "system health CPU event did not use fake loadavg pressure", + ); + invariant( + cpuEvent?.details?.loadPerCore >= 0.1, + "system health CPU event did not preserve per-core pressure", + ); + invariant(diskRecoveryEvent?.severity === "info", "system health disk recovery was not info"); + invariant( + diskRecoveryEvent?.details?.usedPercent === 10, + "system health disk recovery did not use fake df recovery", + ); + invariant(cpuRecoveryEvent?.severity === "info", "system health CPU recovery was not info"); + invariant( + cpuRecoveryEvent?.details?.loadAverageOneMinute === 0, + "system health CPU recovery did not use fake loadavg recovery", + ); + invariant(localEvent, "system health disk pressure event was not written locally"); + invariant(localCpuEvent, "system health CPU pressure event was not written locally"); + invariant(localDiskRecoveryEvent, "system health disk recovery event was not written locally"); + invariant(localCpuRecoveryEvent, "system health CPU recovery event was not written locally"); + setActiveScenario(undefined); +} + +export async function runClockSkewRecoveryScenario({ + address, + captureCommand, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "clock-skew-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "clock-skew-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { + expectSuccess: true, + name: "clock-skew-recovery", + nodeHeartbeatDateHeaders: [ + () => new Date(Date.now() + 10_000).toUTCString(), + () => new Date().toUTCString(), + ], + }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.system.clock_skew") && + observed.healthEvents.some( + (event) => event.type === "agent.system.clock_skew_recovered", + ) && + observed.nodeHeartbeats >= 2, + 20_000, + () => + `heartbeats=${observed.nodeHeartbeats} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const healthLogEvents = await readJsonLines(healthLogFile); + const skewEvent = observed.healthEvents.find( + (event) => event.type === "agent.system.clock_skew", + ); + const recoveredEvent = observed.healthEvents.find( + (event) => event.type === "agent.system.clock_skew_recovered", + ); + const localSkewEvent = healthLogEvents.find( + (event) => event.type === "agent.system.clock_skew", + ); + const localRecoveredEvent = healthLogEvents.find( + (event) => event.type === "agent.system.clock_skew_recovered", + ); + + invariant(skewEvent?.severity === "warning", "clock skew event was not warning"); + invariant( + skewEvent?.details?.absoluteSkewSeconds > 5, + "clock skew event did not preserve warning skew evidence", + ); + invariant( + skewEvent?.details?.warningSeconds === 5, + "clock skew event did not include warning threshold", + ); + invariant(recoveredEvent?.severity === "info", "clock skew recovery was not info"); + invariant( + recoveredEvent?.details?.absoluteSkewSeconds <= 2, + "clock skew recovery did not preserve recovered skew evidence", + ); + invariant(localSkewEvent, "clock skew event was not written locally"); + invariant(localRecoveredEvent, "clock skew recovery event was not written locally"); + setActiveScenario(undefined); +} + +export async function runNodeHeartbeatRecoveryScenario({ + address, + captureCommand, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "node-heartbeat-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "node-heartbeat-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { + expectSuccess: true, + name: "node-heartbeat-recovery", + nodeHeartbeatFailuresRemaining: 1, + }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.node_heartbeat.sync_failed") && + observed.healthEvents.some( + (event) => event.type === "agent.node_heartbeat.sync_recovered", + ) && + observed.nodeHeartbeatFailures === 1 && + observed.nodeHeartbeats >= 1, + 20_000, + () => + `heartbeats=${observed.nodeHeartbeats} heartbeatFailures=${observed.nodeHeartbeatFailures} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const healthLogEvents = await readJsonLines(healthLogFile); + const failedEvent = observed.healthEvents.find( + (event) => event.type === "agent.node_heartbeat.sync_failed", + ); + const recoveredEvent = observed.healthEvents.find( + (event) => event.type === "agent.node_heartbeat.sync_recovered", + ); + + invariant(failedEvent?.severity === "warning", "node heartbeat sync failure was not warning"); + invariant( + String(failedEvent?.details?.error).includes("controller rejected node heartbeat with 503"), + "node heartbeat sync failure did not preserve controller rejection", + ); + invariant( + failedEvent?.details?.nodeId === "node_fake_controller_smoke", + "node heartbeat sync failure did not preserve node id", + ); + invariant( + failedEvent?.details?.alias === "Local Recorder Node", + "node heartbeat sync failure did not preserve node alias", + ); + invariant( + failedEvent?.details?.status === "online", + "node heartbeat sync failure did not preserve node status", + ); + invariant( + failedEvent?.details?.interfaceCount >= 1, + "node heartbeat sync failure did not preserve interface count", + ); + invariant( + Array.isArray(failedEvent?.details?.audioBackends), + "node heartbeat sync failure did not preserve audio backends", + ); + invariant(recoveredEvent?.severity === "info", "node heartbeat sync recovery was not info"); + invariant( + recoveredEvent?.details?.nodeId === failedEvent.details.nodeId, + "node heartbeat sync recovery did not preserve node id", + ); + invariant( + recoveredEvent?.details?.interfaceCount >= 1, + "node heartbeat sync recovery did not preserve interface count", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.node_heartbeat.sync_failed"), + "node heartbeat sync failure event was not written locally", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.node_heartbeat.sync_recovered"), + "node heartbeat sync recovery event was not written locally", + ); + setActiveScenario(undefined); +} + +export async function runNodeConfigRecoveryScenario({ + address, + captureCommand, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "node-config-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "node-config-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { + expectSuccess: true, + name: "node-config-recovery", + nodeConfigFailuresRemaining: 1, + }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.node_config.sync_failed") && + observed.healthEvents.some((event) => event.type === "agent.node_config.sync_recovered") && + observed.nodeConfigFailures === 1 && + observed.configReads >= 2, + 20_000, + () => + `configReads=${observed.configReads} configFailures=${observed.nodeConfigFailures} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const healthLogEvents = await readJsonLines(healthLogFile); + const failedEvent = observed.healthEvents.find( + (event) => event.type === "agent.node_config.sync_failed", + ); + const recoveredEvent = observed.healthEvents.find( + (event) => event.type === "agent.node_config.sync_recovered", + ); + + invariant(failedEvent?.severity === "warning", "node config sync failure was not warning"); + invariant( + String(failedEvent?.details?.error).includes( + "controller rejected node config request with 503", + ), + "node config sync failure did not preserve controller rejection", + ); + invariant( + failedEvent?.details?.nodeId === "node_fake_controller_smoke", + "node config sync failure did not preserve node id", + ); + invariant( + failedEvent?.details?.maxConcurrentRecordings === null, + "node config sync failure should not invent capacity evidence", + ); + invariant(recoveredEvent?.severity === "info", "node config sync recovery was not info"); + invariant( + recoveredEvent?.details?.nodeId === failedEvent.details.nodeId, + "node config sync recovery did not preserve node id", + ); + invariant( + recoveredEvent?.details?.maxConcurrentRecordings === 1, + "node config sync recovery did not preserve capacity", + ); + invariant( + recoveredEvent?.details?.recorderCachePolicyCount === 0, + "node config sync recovery did not preserve recorder-cache policy count", + ); + invariant( + recoveredEvent?.details?.audioDefaultsConfigured === false, + "node config sync recovery did not preserve audio-default state", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.node_config.sync_failed"), + "node config sync failure event was not written locally", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.node_config.sync_recovered"), + "node config sync recovery event was not written locally", + ); + setActiveScenario(undefined); +} + +function fakeDfCommandPath(fakeDfPath) { + return path.join(fakeDfPath, process.platform === "win32" ? "df.cmd" : "df"); +} + +export async function runAudioBackendRecoveryScenario({ + address, + captureCommand, + createObserved, + fakeArecordCommand, + fakeArecordPath, + fakeProcAsoundPcmPath, + renderCommand, + setActiveScenario, + smokeRoot, + spawnDaemonAgent, +}) { + const stateFile = path.join(smokeRoot, "audio-backend-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "audio-backend-recovery-health-events.jsonl"); + const observed = createObserved(); + setActiveScenario({ + jobs: [], + observed, + scenario: { expectSuccess: true, name: "audio-backend-recovery" }, + }); + const child = spawnDaemonAgent({ + address, + captureCommand, + extraAgentArgs: [ + "--inventory-arecord-command", + fakeArecordCommand, + "--inventory-proc-asound-pcm-path", + fakeProcAsoundPcmPath, + ], + extraEnv: { + PATH: `${fakeArecordPath}${path.delimiter}${process.env.PATH ?? ""}`, + }, + healthLogFile, + renderCommand, + stateFile, + }); + + try { + await waitFor( + () => + observed.healthEvents.some((event) => event.type === "agent.audio_backend.unavailable") && + observed.healthEvents.some((event) => event.type === "agent.audio_backend.recovered") && + observed.nodeHeartbeats >= 1 && + observed.meterFrames >= 1, + 20_000, + () => + `heartbeats=${observed.nodeHeartbeats} meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const healthLogEvents = await readJsonLines(healthLogFile); + const unavailableEvent = observed.healthEvents.find( + (event) => event.type === "agent.audio_backend.unavailable", + ); + const recoveredEvent = observed.healthEvents.find( + (event) => event.type === "agent.audio_backend.recovered", + ); + + invariant(unavailableEvent?.severity === "warning", "audio backend unavailable was not warning"); + invariant( + unavailableEvent?.details?.availableInterfaces === 0, + "audio backend unavailable did not report zero available interfaces", + ); + invariant( + unavailableEvent?.details?.interfaces === 1, + "audio backend unavailable did not preserve fallback interface count", + ); + invariant( + unavailableEvent?.details?.audioBackends?.includes("unknown"), + "audio backend unavailable did not preserve unknown backend evidence", + ); + invariant(recoveredEvent?.severity === "info", "audio backend recovery was not info"); + invariant( + recoveredEvent?.details?.availableInterfaces === 1, + "audio backend recovery did not report available interface", + ); + invariant( + recoveredEvent?.details?.audioBackends?.includes("alsa"), + "audio backend recovery did not preserve recovered ALSA backend", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.audio_backend.unavailable"), + "audio backend unavailable event was not written locally", + ); + invariant( + healthLogEvents.some((event) => event.type === "agent.audio_backend.recovered"), + "audio backend recovery event was not written locally", + ); + setActiveScenario(undefined); +} diff --git a/scripts/agent-fake-controller-smoke-health.mjs b/scripts/agent-fake-controller-smoke-health.mjs index dafd567..ef807b9 100644 --- a/scripts/agent-fake-controller-smoke-health.mjs +++ b/scripts/agent-fake-controller-smoke-health.mjs @@ -1,923 +1,5 @@ -import path from "node:path"; - -import { invariant, readJsonLines, waitFor } from "./agent-fake-controller-smoke-utils.mjs"; - -export async function runSystemHealthScenario({ - address, - captureCommand, - createObserved, - fakeDfPath, - fakeLoadavgPath, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "system-health-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "system-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { - expectSuccess: true, - name: "system-health", - }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - extraAgentArgs: [ - "--system-health-df-command", - fakeDfCommandPath(fakeDfPath), - "--system-health-load-warning-per-core", - "0.01", - "--system-health-load-critical-per-core", - "1000", - "--system-health-loadavg-path", - fakeLoadavgPath, - ], - extraEnv: { - PATH: `${fakeDfPath}${path.delimiter}${process.env.PATH ?? ""}`, - }, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.system.disk_pressure") && - observed.healthEvents.some((event) => event.type === "agent.system.cpu_pressure") && - observed.healthEvents.some((event) => event.type === "agent.system.disk_recovered") && - observed.healthEvents.some((event) => event.type === "agent.system.cpu_recovered") && - observed.nodeHeartbeats >= 2 && - observed.inventoryReconciles >= 1 && - observed.meterFrames >= 2, - 20_000, - () => - `heartbeats=${observed.nodeHeartbeats} inventory=${observed.inventoryReconciles} meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - const healthLogEvents = await readJsonLines(healthLogFile); - const syncedEvent = observed.healthEvents.find( - (event) => event.type === "agent.system.disk_pressure", - ); - const cpuEvent = observed.healthEvents.find( - (event) => event.type === "agent.system.cpu_pressure", - ); - const diskRecoveryEvent = observed.healthEvents.find( - (event) => event.type === "agent.system.disk_recovered", - ); - const cpuRecoveryEvent = observed.healthEvents.find( - (event) => event.type === "agent.system.cpu_recovered", - ); - const localEvent = healthLogEvents.find((event) => event.type === "agent.system.disk_pressure"); - const localCpuEvent = healthLogEvents.find( - (event) => event.type === "agent.system.cpu_pressure", - ); - const localDiskRecoveryEvent = healthLogEvents.find( - (event) => event.type === "agent.system.disk_recovered", - ); - const localCpuRecoveryEvent = healthLogEvents.find( - (event) => event.type === "agent.system.cpu_recovered", - ); - - invariant(syncedEvent?.severity === "warning", "system health disk event was not warning"); - invariant( - syncedEvent?.details?.usedPercent === 90, - "system health disk event did not use fake df pressure", - ); - invariant(cpuEvent?.severity === "warning", "system health CPU event was not warning"); - invariant( - cpuEvent?.details?.loadAverageOneMinute === 12.5, - "system health CPU event did not use fake loadavg pressure", - ); - invariant( - cpuEvent?.details?.loadPerCore >= 0.1, - "system health CPU event did not preserve per-core pressure", - ); - invariant(diskRecoveryEvent?.severity === "info", "system health disk recovery was not info"); - invariant( - diskRecoveryEvent?.details?.usedPercent === 10, - "system health disk recovery did not use fake df recovery", - ); - invariant(cpuRecoveryEvent?.severity === "info", "system health CPU recovery was not info"); - invariant( - cpuRecoveryEvent?.details?.loadAverageOneMinute === 0, - "system health CPU recovery did not use fake loadavg recovery", - ); - invariant(localEvent, "system health disk pressure event was not written locally"); - invariant(localCpuEvent, "system health CPU pressure event was not written locally"); - invariant(localDiskRecoveryEvent, "system health disk recovery event was not written locally"); - invariant(localCpuRecoveryEvent, "system health CPU recovery event was not written locally"); - setActiveScenario(undefined); -} - -export async function runClockSkewRecoveryScenario({ - address, - captureCommand, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "clock-skew-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "clock-skew-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { - expectSuccess: true, - name: "clock-skew-recovery", - nodeHeartbeatDateHeaders: [ - () => new Date(Date.now() + 10_000).toUTCString(), - () => new Date().toUTCString(), - ], - }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.system.clock_skew") && - observed.healthEvents.some( - (event) => event.type === "agent.system.clock_skew_recovered", - ) && - observed.nodeHeartbeats >= 2, - 20_000, - () => - `heartbeats=${observed.nodeHeartbeats} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const healthLogEvents = await readJsonLines(healthLogFile); - const skewEvent = observed.healthEvents.find( - (event) => event.type === "agent.system.clock_skew", - ); - const recoveredEvent = observed.healthEvents.find( - (event) => event.type === "agent.system.clock_skew_recovered", - ); - const localSkewEvent = healthLogEvents.find( - (event) => event.type === "agent.system.clock_skew", - ); - const localRecoveredEvent = healthLogEvents.find( - (event) => event.type === "agent.system.clock_skew_recovered", - ); - - invariant(skewEvent?.severity === "warning", "clock skew event was not warning"); - invariant( - skewEvent?.details?.absoluteSkewSeconds > 5, - "clock skew event did not preserve warning skew evidence", - ); - invariant( - skewEvent?.details?.warningSeconds === 5, - "clock skew event did not include warning threshold", - ); - invariant(recoveredEvent?.severity === "info", "clock skew recovery was not info"); - invariant( - recoveredEvent?.details?.absoluteSkewSeconds <= 2, - "clock skew recovery did not preserve recovered skew evidence", - ); - invariant(localSkewEvent, "clock skew event was not written locally"); - invariant(localRecoveredEvent, "clock skew recovery event was not written locally"); - setActiveScenario(undefined); -} - -export async function runMeterXrunScenario({ - address, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, - xrunMeterCommand, -}) { - const stateFile = path.join(smokeRoot, "meter-xrun-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "meter-xrun-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ jobs: [], observed, scenario: { expectSuccess: true, name: "meter-xrun" } }); - const child = spawnDaemonAgent({ - address, - captureCommand: xrunMeterCommand, - extraAgentArgs: ["--meter-backend", "alsa"], - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.meter.xrun") && - observed.meterFrames >= 1 && - observed.monitorChunks.length >= 1, - 20_000, - () => - `meters=${observed.meterFrames} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - const healthLogEvents = await readJsonLines(healthLogFile); - const syncedEvent = observed.healthEvents.find((event) => event.type === "agent.meter.xrun"); - - invariant(syncedEvent?.severity === "warning", "meter xrun event was not warning"); - invariant( - syncedEvent?.details?.usingSyntheticFallback === true, - "meter xrun did not report synthetic fallback", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.meter.xrun"), - "meter xrun event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runMeterFrameSyncRecoveryScenario({ - address, - captureCommand, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "meter-frame-sync-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "meter-frame-sync-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { - expectSuccess: true, - meterFrameFailuresRemaining: 1, - name: "meter-frame-sync-recovery", - }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.meter_frame.sync_failed") && - observed.healthEvents.some((event) => event.type === "agent.meter_frame.sync_recovered") && - observed.meterFrameFailures === 1 && - observed.meterFrames >= 1, - 20_000, - () => - `meterFailures=${observed.meterFrameFailures} meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const healthLogEvents = await readJsonLines(healthLogFile); - const failedEvent = observed.healthEvents.find( - (event) => event.type === "agent.meter_frame.sync_failed", - ); - const recoveredEvent = observed.healthEvents.find( - (event) => event.type === "agent.meter_frame.sync_recovered", - ); - - invariant(failedEvent?.severity === "warning", "meter-frame sync failure was not warning"); - invariant( - String(failedEvent?.details?.error).includes("controller rejected meter frame with 503"), - "meter-frame sync failure did not preserve controller rejection", - ); - invariant( - failedEvent?.details?.interfaceId === "iface_default_capture", - "meter-frame sync failure did not preserve interface id", - ); - invariant( - failedEvent?.details?.channelCount === 2, - "meter-frame sync failure did not preserve channel count", - ); - invariant(recoveredEvent?.severity === "info", "meter-frame sync recovery was not info"); - invariant( - recoveredEvent?.details?.interfaceId === failedEvent.details.interfaceId, - "meter-frame sync recovery did not preserve interface id", - ); - invariant( - recoveredEvent?.details?.channelCount === failedEvent.details.channelCount, - "meter-frame sync recovery did not preserve channel count", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.meter_frame.sync_failed"), - "meter-frame sync failure event was not written locally", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.meter_frame.sync_recovered"), - "meter-frame sync recovery event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runMeterDeviceUnavailableScenario({ - address, - createObserved, - deviceUnavailableMeterCommand, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "meter-device-unavailable-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "meter-device-unavailable-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { expectSuccess: true, name: "meter-device-unavailable" }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand: deviceUnavailableMeterCommand, - extraAgentArgs: ["--meter-backend", "alsa"], - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.meter.device_unavailable") && - observed.meterFrames >= 1 && - observed.monitorChunks.length >= 1, - 20_000, - () => - `meters=${observed.meterFrames} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - const healthLogEvents = await readJsonLines(healthLogFile); - const syncedEvent = observed.healthEvents.find( - (event) => event.type === "agent.meter.device_unavailable", - ); - - invariant( - syncedEvent?.severity === "critical", - "meter device unavailable event was not critical", - ); - invariant( - syncedEvent?.details?.usingSyntheticFallback === true, - "meter device unavailable did not report synthetic fallback", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.meter.device_unavailable"), - "meter device unavailable event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runMeterCaptureFailedScenario({ - address, - captureFailedMeterCommand, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "meter-capture-failed-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "meter-capture-failed-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { expectSuccess: true, name: "meter-capture-failed" }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand: captureFailedMeterCommand, - extraAgentArgs: ["--meter-backend", "alsa"], - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.meter.capture_failed") && - observed.meterFrames >= 1 && - observed.monitorChunks.length >= 1, - 20_000, - () => - `meters=${observed.meterFrames} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - const healthLogEvents = await readJsonLines(healthLogFile); - const syncedEvent = observed.healthEvents.find( - (event) => event.type === "agent.meter.capture_failed", - ); - - invariant(syncedEvent?.severity === "warning", "meter capture failure was not warning"); - invariant( - syncedEvent?.details?.classification === "capture_failed", - "meter capture failure did not preserve generic classification", - ); - invariant( - syncedEvent?.details?.usingSyntheticFallback === true, - "meter capture failure did not report synthetic fallback", - ); - invariant( - !observed.healthEvents.some((event) => event.type === "agent.meter.xrun"), - "generic meter capture failure was misclassified as xrun", - ); - invariant( - !observed.healthEvents.some((event) => event.type === "agent.meter.device_unavailable"), - "generic meter capture failure was misclassified as device unavailable", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.meter.capture_failed"), - "meter capture failure event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runMeterRecoveryScenario({ - address, - createObserved, - recoveringMeterCommand, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "meter-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "meter-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { expectSuccess: true, name: "meter-recovery" }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand: recoveringMeterCommand, - extraAgentArgs: ["--meter-backend", "alsa"], - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.meter.xrun") && - observed.healthEvents.some((event) => event.type === "agent.meter.capture_recovered") && - observed.meterFrames >= 2, - 20_000, - () => - `meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - const healthLogEvents = await readJsonLines(healthLogFile); - const recoveryEvent = observed.healthEvents.find( - (event) => event.type === "agent.meter.capture_recovered", - ); - - invariant(recoveryEvent?.severity === "info", "meter recovery event was not info"); - invariant( - recoveryEvent?.details?.previousType === "agent.meter.xrun", - "meter recovery did not preserve previous xrun type", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.meter.capture_recovered"), - "meter recovery event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runMonitorChunkRecoveryScenario({ - address, - captureCommand, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "monitor-chunk-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "monitor-chunk-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { - expectSuccess: true, - monitorChunkFailuresRemaining: 1, - name: "monitor-chunk-recovery", - }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some( - (event) => event.type === "agent.listen_monitor.chunk_sync_failed", - ) && - observed.healthEvents.some( - (event) => event.type === "agent.listen_monitor.chunk_sync_recovered", - ) && - observed.monitorChunkFailures === 1 && - observed.monitorChunks.length >= 1, - 20_000, - () => - `monitorFailures=${observed.monitorChunkFailures} monitor=${observed.monitorChunks.length} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const healthLogEvents = await readJsonLines(healthLogFile); - const failedEvent = observed.healthEvents.find( - (event) => event.type === "agent.listen_monitor.chunk_sync_failed", - ); - const recoveredEvent = observed.healthEvents.find( - (event) => event.type === "agent.listen_monitor.chunk_sync_recovered", - ); - const uploadedChunk = observed.monitorChunks.at(0); - - invariant(failedEvent?.severity === "warning", "monitor chunk failure was not warning"); - invariant( - String(failedEvent?.details?.error).includes("controller rejected monitor chunk with 503"), - "monitor chunk failure did not preserve controller rejection", - ); - invariant( - failedEvent?.details?.contentType === "audio/wav", - "monitor chunk failure did not preserve content type", - ); - invariant( - failedEvent?.details?.durationMs === 1000, - "monitor chunk failure did not preserve duration", - ); - invariant( - failedEvent?.details?.monitorBytes > 44, - "monitor chunk failure did not preserve monitor bytes", - ); - invariant( - failedEvent?.details?.interfaceId === "iface_default_capture", - "monitor chunk failure did not preserve interface id", - ); - invariant(recoveredEvent?.severity === "info", "monitor chunk recovery was not info"); - invariant( - recoveredEvent?.details?.contentType === "audio/wav", - "monitor chunk recovery did not preserve content type", - ); - invariant( - recoveredEvent?.details?.monitorBytes > 44, - "monitor chunk recovery did not preserve monitor bytes", - ); - invariant( - uploadedChunk?.contentType === "audio/wav", - "fake controller did not receive monitor chunk content type", - ); - invariant(uploadedChunk?.durationMs === "1000", "fake controller did not receive duration"); - invariant(uploadedChunk?.size > 44, "fake controller did not receive monitor bytes"); - invariant( - healthLogEvents.some((event) => event.type === "agent.listen_monitor.chunk_sync_failed"), - "monitor chunk failure event was not written locally", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.listen_monitor.chunk_sync_recovered"), - "monitor chunk recovery event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runNodeHeartbeatRecoveryScenario({ - address, - captureCommand, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "node-heartbeat-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "node-heartbeat-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { - expectSuccess: true, - name: "node-heartbeat-recovery", - nodeHeartbeatFailuresRemaining: 1, - }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.node_heartbeat.sync_failed") && - observed.healthEvents.some( - (event) => event.type === "agent.node_heartbeat.sync_recovered", - ) && - observed.nodeHeartbeatFailures === 1 && - observed.nodeHeartbeats >= 1, - 20_000, - () => - `heartbeats=${observed.nodeHeartbeats} heartbeatFailures=${observed.nodeHeartbeatFailures} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const healthLogEvents = await readJsonLines(healthLogFile); - const failedEvent = observed.healthEvents.find( - (event) => event.type === "agent.node_heartbeat.sync_failed", - ); - const recoveredEvent = observed.healthEvents.find( - (event) => event.type === "agent.node_heartbeat.sync_recovered", - ); - - invariant(failedEvent?.severity === "warning", "node heartbeat sync failure was not warning"); - invariant( - String(failedEvent?.details?.error).includes("controller rejected node heartbeat with 503"), - "node heartbeat sync failure did not preserve controller rejection", - ); - invariant( - failedEvent?.details?.nodeId === "node_fake_controller_smoke", - "node heartbeat sync failure did not preserve node id", - ); - invariant( - failedEvent?.details?.alias === "Local Recorder Node", - "node heartbeat sync failure did not preserve node alias", - ); - invariant( - failedEvent?.details?.status === "online", - "node heartbeat sync failure did not preserve node status", - ); - invariant( - failedEvent?.details?.interfaceCount >= 1, - "node heartbeat sync failure did not preserve interface count", - ); - invariant( - Array.isArray(failedEvent?.details?.audioBackends), - "node heartbeat sync failure did not preserve audio backends", - ); - invariant(recoveredEvent?.severity === "info", "node heartbeat sync recovery was not info"); - invariant( - recoveredEvent?.details?.nodeId === failedEvent.details.nodeId, - "node heartbeat sync recovery did not preserve node id", - ); - invariant( - recoveredEvent?.details?.interfaceCount >= 1, - "node heartbeat sync recovery did not preserve interface count", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.node_heartbeat.sync_failed"), - "node heartbeat sync failure event was not written locally", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.node_heartbeat.sync_recovered"), - "node heartbeat sync recovery event was not written locally", - ); - setActiveScenario(undefined); -} - -export async function runNodeConfigRecoveryScenario({ - address, - captureCommand, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "node-config-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "node-config-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { - expectSuccess: true, - name: "node-config-recovery", - nodeConfigFailuresRemaining: 1, - }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.node_config.sync_failed") && - observed.healthEvents.some((event) => event.type === "agent.node_config.sync_recovered") && - observed.nodeConfigFailures === 1 && - observed.configReads >= 2, - 20_000, - () => - `configReads=${observed.configReads} configFailures=${observed.nodeConfigFailures} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const healthLogEvents = await readJsonLines(healthLogFile); - const failedEvent = observed.healthEvents.find( - (event) => event.type === "agent.node_config.sync_failed", - ); - const recoveredEvent = observed.healthEvents.find( - (event) => event.type === "agent.node_config.sync_recovered", - ); - - invariant(failedEvent?.severity === "warning", "node config sync failure was not warning"); - invariant( - String(failedEvent?.details?.error).includes( - "controller rejected node config request with 503", - ), - "node config sync failure did not preserve controller rejection", - ); - invariant( - failedEvent?.details?.nodeId === "node_fake_controller_smoke", - "node config sync failure did not preserve node id", - ); - invariant( - failedEvent?.details?.maxConcurrentRecordings === null, - "node config sync failure should not invent capacity evidence", - ); - invariant(recoveredEvent?.severity === "info", "node config sync recovery was not info"); - invariant( - recoveredEvent?.details?.nodeId === failedEvent.details.nodeId, - "node config sync recovery did not preserve node id", - ); - invariant( - recoveredEvent?.details?.maxConcurrentRecordings === 1, - "node config sync recovery did not preserve capacity", - ); - invariant( - recoveredEvent?.details?.recorderCachePolicyCount === 0, - "node config sync recovery did not preserve recorder-cache policy count", - ); - invariant( - recoveredEvent?.details?.audioDefaultsConfigured === false, - "node config sync recovery did not preserve audio-default state", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.node_config.sync_failed"), - "node config sync failure event was not written locally", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.node_config.sync_recovered"), - "node config sync recovery event was not written locally", - ); - setActiveScenario(undefined); -} - -function fakeDfCommandPath(fakeDfPath) { - return path.join(fakeDfPath, process.platform === "win32" ? "df.cmd" : "df"); -} - -export async function runAudioBackendRecoveryScenario({ - address, - captureCommand, - createObserved, - fakeArecordCommand, - fakeArecordPath, - fakeProcAsoundPcmPath, - renderCommand, - setActiveScenario, - smokeRoot, - spawnDaemonAgent, -}) { - const stateFile = path.join(smokeRoot, "audio-backend-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "audio-backend-recovery-health-events.jsonl"); - const observed = createObserved(); - setActiveScenario({ - jobs: [], - observed, - scenario: { expectSuccess: true, name: "audio-backend-recovery" }, - }); - const child = spawnDaemonAgent({ - address, - captureCommand, - extraAgentArgs: [ - "--inventory-arecord-command", - fakeArecordCommand, - "--inventory-proc-asound-pcm-path", - fakeProcAsoundPcmPath, - ], - extraEnv: { - PATH: `${fakeArecordPath}${path.delimiter}${process.env.PATH ?? ""}`, - }, - healthLogFile, - renderCommand, - stateFile, - }); - - try { - await waitFor( - () => - observed.healthEvents.some((event) => event.type === "agent.audio_backend.unavailable") && - observed.healthEvents.some((event) => event.type === "agent.audio_backend.recovered") && - observed.nodeHeartbeats >= 1 && - observed.meterFrames >= 1, - 20_000, - () => - `heartbeats=${observed.nodeHeartbeats} meters=${observed.meterFrames} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const healthLogEvents = await readJsonLines(healthLogFile); - const unavailableEvent = observed.healthEvents.find( - (event) => event.type === "agent.audio_backend.unavailable", - ); - const recoveredEvent = observed.healthEvents.find( - (event) => event.type === "agent.audio_backend.recovered", - ); - - invariant(unavailableEvent?.severity === "warning", "audio backend unavailable was not warning"); - invariant( - unavailableEvent?.details?.availableInterfaces === 0, - "audio backend unavailable did not report zero available interfaces", - ); - invariant( - unavailableEvent?.details?.interfaces === 1, - "audio backend unavailable did not preserve fallback interface count", - ); - invariant( - unavailableEvent?.details?.audioBackends?.includes("unknown"), - "audio backend unavailable did not preserve unknown backend evidence", - ); - invariant(recoveredEvent?.severity === "info", "audio backend recovery was not info"); - invariant( - recoveredEvent?.details?.availableInterfaces === 1, - "audio backend recovery did not report available interface", - ); - invariant( - recoveredEvent?.details?.audioBackends?.includes("alsa"), - "audio backend recovery did not preserve recovered ALSA backend", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.audio_backend.unavailable"), - "audio backend unavailable event was not written locally", - ); - invariant( - healthLogEvents.some((event) => event.type === "agent.audio_backend.recovered"), - "audio backend recovery event was not written locally", - ); - setActiveScenario(undefined); -} +// Fake-controller health/recovery scenarios. The scenario functions live in two +// cohesive sibling modules (system/node lifecycle vs. meter/monitor data plane); +// this module re-exports them so the smoke entry point keeps one import surface. +export * from "./agent-fake-controller-smoke-health-system.mjs"; +export * from "./agent-fake-controller-smoke-health-meters.mjs"; diff --git a/scripts/agent-fake-controller-smoke-recovery-disk.mjs b/scripts/agent-fake-controller-smoke-recovery-disk.mjs new file mode 100644 index 0000000..d3737a9 --- /dev/null +++ b/scripts/agent-fake-controller-smoke-recovery-disk.mjs @@ -0,0 +1,489 @@ +import { existsSync, readFileSync } from "node:fs"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { spawnDaemonAgent } from "./agent-fake-controller-smoke-agent.mjs"; +import { + writeDeviceAssertCaptureCommand, + writeFakeRuntimeDiskRecoveryCaptureCommand, + writeRenumberedAudioInventoryFixtures, +} from "./agent-fake-controller-smoke-support.mjs"; +import { fileExists, invariant, readJsonLines, waitFor } from "./agent-fake-controller-smoke-utils.mjs"; + +export async function runDiskPreflightRecoveryScenario({ + address, + agentContext, + captureCommand, + createJob, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, +}) { + const stateFile = path.join(smokeRoot, "disk-preflight-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "disk-preflight-recovery-health-events.jsonl"); + const manifestFile = path.join(smokeRoot, "disk-preflight-recovery-manifest.json"); + const cacheRoot = path.join(smokeRoot, "data", "recordings", "local-captures"); + const staleRawPath = path.join(cacheRoot, "rec_fake_controller_stale.raw.wav"); + const staleOutputPath = path.join(cacheRoot, "rec_fake_controller_stale.mp3"); + const fakeDfPath = await writeDiskPreflightDfCommand(smokeRoot); + const retention = { + deleteAfterUpload: false, + maxAgeDays: null, + maxBytes: null, + minFreeDiskPercent: 95, + policyId: "retention-disk-preflight-recovery-smoke", + }; + const job = createJob({ + captureChannels: 4, + durationSeconds: 2, + expectSuccess: true, + jobId: "job_fake_controller_disk_preflight_recovery", + name: "disk-preflight-recovery", + outputFileName: "rec_fake_controller_disk_preflight_recovery.mp3", + recordingId: "rec_fake_controller_disk_preflight_recovery", + recorderCacheRetention: retention, + }); + const observed = createObserved(); + + await mkdir(cacheRoot, { recursive: true }); + await writeFile(staleRawPath, Buffer.alloc(128, 1)); + await writeFile(staleOutputPath, Buffer.alloc(256, 2)); + await writeFile( + manifestFile, + JSON.stringify( + { + entries: [ + { + outputPath: staleOutputPath, + policyId: retention.policyId, + rawOutputPath: staleRawPath, + recordingId: "rec_fake_controller_stale", + }, + ], + version: 1, + }, + null, + 2, + ), + ); + + setActiveScenario({ + job, + jobs: [job], + observed, + scenario: { + expectSuccess: true, + name: "disk-preflight-recovery", + recorderCachePolicySequence: [[], [retention]], + }, + }); + + const child = spawnDaemonAgent( + agentContext, + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + { + PATH: `${fakeDfPath}${path.delimiter}${process.env.PATH ?? ""}`, + }, + [ + "--recorder-cache-manifest-file", + manifestFile, + "--system-health-df-command", + fakeDfCommandPath(fakeDfPath), + ], + ); + + try { + await waitFor( + () => + job.status === "completed" && + observed.cacheUpload && + observed.healthEvents.some( + (event) => event.type === "agent.recording_job.disk_space_recovered", + ) && + agentStateStatus(stateFile) === "completed", + 30_000, + () => + `job=${job.status} upload=${Boolean(observed.cacheUpload)} state=${agentStateStatus(stateFile) ?? ""} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const state = JSON.parse(await readFile(stateFile, "utf8")); + const healthLogEvents = await readJsonLines(healthLogFile); + const cleanupEvent = healthLogEvents.find( + (event) => event.type === "agent.recording_job.disk_space_cleanup_attempted", + ); + const recoveredEvent = healthLogEvents.find( + (event) => event.type === "agent.recording_job.disk_space_recovered", + ); + + invariant(job.status === "completed", "disk preflight recovery did not complete the job"); + invariant(state.status === "completed", "disk preflight recovery state did not complete"); + invariant(observed.cacheUploads.length === 1, "disk preflight recovery did not upload once"); + invariant(cleanupEvent?.details?.deleted === 1, "disk preflight cleanup did not delete cache"); + invariant( + cleanupEvent.details?.items?.[0]?.reason === "min_free_disk", + "disk preflight cleanup did not use min-free policy", + ); + invariant( + recoveredEvent?.details?.freeBytes > recoveredEvent?.details?.initialFreeBytes, + "disk preflight recovery did not record improved free space", + ); + invariant(!(await fileExists(staleRawPath)), "disk preflight cleanup left stale raw cache"); + invariant(!(await fileExists(staleOutputPath)), "disk preflight cleanup left stale output cache"); + setActiveScenario(undefined); +} + +export async function runRuntimeDiskRecoveryScenario({ + address, + agentContext, + createJob, + createObserved, + nodeId: _nodeId, + renderCommand, + setActiveScenario, + smokeRoot, +}) { + // Use a longer-lived capture so the recording-job disk monitor reliably polls + // df while the capture is still in flight (the default 750 ms capture can be + // spawned and exit inside one 1 s poll interval on a slow runner, so the + // runtime disk-exhaustion window is never observed — see the capture command). + const captureCommand = await writeFakeRuntimeDiskRecoveryCaptureCommand(smokeRoot); + const stateFile = path.join(smokeRoot, "runtime-disk-recovery-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "runtime-disk-recovery-health-events.jsonl"); + const manifestFile = path.join(smokeRoot, "runtime-disk-recovery-manifest.json"); + const cacheRoot = path.join(smokeRoot, "data", "recordings", "local-captures"); + const rawCapturePath = path.join( + cacheRoot, + "rec_fake_controller_runtime_disk_recovery.raw.wav", + ); + const staleRawPath = path.join(cacheRoot, "rec_fake_controller_runtime_stale.raw.wav"); + const staleOutputPath = path.join(cacheRoot, "rec_fake_controller_runtime_stale.mp3"); + const fakeDfPath = await writeRuntimeDiskRecoveryDfCommand( + smokeRoot, + rawCapturePath, + staleOutputPath, + ); + const retention = { + deleteAfterUpload: false, + maxAgeDays: null, + maxBytes: null, + minFreeDiskPercent: 95, + policyId: "retention-runtime-disk-recovery-smoke", + }; + const job = createJob({ + durationSeconds: 2, + expectSuccess: true, + jobId: "job_fake_controller_runtime_disk_recovery", + name: "runtime-disk-recovery", + outputFileName: "rec_fake_controller_runtime_disk_recovery.mp3", + recordingId: "rec_fake_controller_runtime_disk_recovery", + recorderCacheRetention: retention, + }); + const observed = createObserved(); + + await mkdir(cacheRoot, { recursive: true }); + await writeFile(staleRawPath, Buffer.alloc(128, 3)); + await writeFile(staleOutputPath, Buffer.alloc(256, 4)); + await writeFile( + manifestFile, + JSON.stringify( + { + entries: [ + { + outputPath: staleOutputPath, + policyId: retention.policyId, + rawOutputPath: staleRawPath, + recordingId: "rec_fake_controller_runtime_stale", + }, + ], + version: 1, + }, + null, + 2, + ), + ); + + setActiveScenario({ + job, + jobs: [job], + observed, + scenario: { + expectSuccess: true, + name: "runtime-disk-recovery", + recorderCachePolicies: [retention], + }, + }); + + const child = spawnDaemonAgent( + agentContext, + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + { + PATH: `${fakeDfPath}${path.delimiter}${process.env.PATH ?? ""}`, + }, + [ + "--recorder-cache-manifest-file", + manifestFile, + "--system-health-df-command", + fakeDfCommandPath(fakeDfPath), + ], + ); + + try { + await waitFor( + () => + job.status === "completed" && + observed.cacheUpload && + observed.healthEvents.some( + (event) => event.type === "agent.recording_job.disk_space_runtime_recovered", + ) && + observed.healthEvents.some( + (event) => event.type === "agent.recording_job.capture_segments_stitched", + ) && + agentStateStatus(stateFile) === "completed", + 30_000, + () => + `job=${job.status} upload=${Boolean(observed.cacheUpload)} state=${agentStateStatus(stateFile) ?? ""} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const state = JSON.parse(await readFile(stateFile, "utf8")); + const healthLogEvents = await readJsonLines(healthLogFile); + const exhaustedEvent = healthLogEvents.find( + (event) => event.type === "agent.recording_job.disk_space_exhausted", + ); + const runtimeRecoveredEvent = healthLogEvents.find( + (event) => event.type === "agent.recording_job.disk_space_runtime_recovered", + ); + const stitchedEvent = healthLogEvents.find( + (event) => event.type === "agent.recording_job.capture_segments_stitched", + ); + + invariant(job.status === "completed", "runtime disk recovery did not complete the job"); + invariant(state.status === "completed", "runtime disk recovery state did not complete"); + invariant(observed.failures === 0, "runtime disk recovery should not mark the job failed"); + invariant(observed.cacheUploads.length === 1, "runtime disk recovery did not upload once"); + invariant(exhaustedEvent, "runtime disk recovery did not log disk exhaustion"); + invariant( + exhaustedEvent.severity === "warning", + "recoverable runtime disk exhaustion was not warning severity", + ); + invariant( + exhaustedEvent.details?.willRetry === true, + "runtime disk exhaustion did not record retry intent", + ); + invariant(runtimeRecoveredEvent, "runtime disk recovery did not log recovery"); + invariant( + runtimeRecoveredEvent.details?.requiredBytes >= runtimeRecoveredEvent.details?.remainingBytes, + "runtime disk recovery did not preserve required byte evidence", + ); + invariant(stitchedEvent, "runtime disk recovery did not stitch the preserved segment"); + invariant(stitchedEvent.details?.segmentCount === 1, "runtime disk recovery lost segment count"); + invariant(!(await fileExists(staleRawPath)), "runtime disk cleanup left stale raw cache"); + invariant(!(await fileExists(staleOutputPath)), "runtime disk cleanup left stale output cache"); + setActiveScenario(undefined); +} + +export async function runCaptureDeviceRenumberingScenario({ + address, + agentContext, + createJob, + createObserved, + renderCommand, + setActiveScenario, + smokeRoot, +}) { + const stateFile = path.join(smokeRoot, "capture-device-renumbering-agent-state.json"); + const healthLogFile = path.join(smokeRoot, "capture-device-renumbering-health-events.jsonl"); + const captureCommand = await writeDeviceAssertCaptureCommand( + smokeRoot, + "hw:CARD=SMOKE,DEV=0", + ); + const inventory = await writeRenumberedAudioInventoryFixtures(smokeRoot); + const job = createJob({ + captureInterfaceId: "alsa_card_smoke_dev_0", + expectSuccess: true, + jobId: "job_fake_controller_capture_device_renumbering", + name: "capture-device-renumbering", + outputFileName: "rec_fake_controller_capture_device_renumbering.mp3", + recordingId: "rec_fake_controller_capture_device_renumbering", + }); + const observed = createObserved(); + job.command.captureDevice = "hw:2,0"; + setActiveScenario({ + job, + jobs: [job], + observed, + scenario: { + expectSuccess: true, + name: "capture-device-renumbering", + }, + }); + + const child = spawnDaemonAgent( + agentContext, + address, + captureCommand, + healthLogFile, + renderCommand, + stateFile, + { + PATH: `${inventory.fakeArecordPath}${path.delimiter}${process.env.PATH ?? ""}`, + }, + [ + "--inventory-arecord-command", + inventory.fakeArecordCommand, + "--inventory-proc-asound-pcm-path", + inventory.fakeProcAsoundPcmPath, + ], + ); + + try { + await waitFor( + () => + job.status === "completed" && + observed.cacheUpload && + observed.healthEvents.some( + (event) => event.type === "agent.recording_job.capture_device_refreshed", + ) && + agentStateStatus(stateFile) === "completed", + 30_000, + () => + `job=${job.status} upload=${Boolean(observed.cacheUpload)} state=${agentStateStatus(stateFile) ?? ""} health=${observed.healthEvents.map((event) => event.type).join(",")}`, + ); + } finally { + child.kill(); + await child.closed; + } + + const state = JSON.parse(await readFile(stateFile, "utf8")); + const healthLogEvents = await readJsonLines(healthLogFile); + const refreshEvent = healthLogEvents.find( + (event) => event.type === "agent.recording_job.capture_device_refreshed", + ); + + invariant(job.status === "completed", "capture device renumbering did not complete the job"); + invariant(state.status === "completed", "capture device renumbering state did not complete"); + invariant(observed.cacheUploads.length === 1, "capture device renumbering did not upload once"); + invariant(refreshEvent, "capture device renumbering did not log refresh"); + invariant( + refreshEvent.details?.captureInterfaceId === "alsa_card_smoke_dev_0", + "capture device renumbering used the wrong stable interface id", + ); + invariant( + refreshEvent.details?.previousDevice === "hw:2,0", + "capture device renumbering did not record the stale device", + ); + invariant( + refreshEvent.details?.refreshedDevice === "hw:CARD=SMOKE,DEV=0", + "capture device renumbering did not record the refreshed device", + ); + setActiveScenario(undefined); +} + +export function agentStateStatus(stateFile) { + if (!existsSync(stateFile)) { + return undefined; + } + + try { + return JSON.parse(readFileSync(stateFile, "utf8")).status; + } catch { + return undefined; + } +} + +function fakeDfCommandPath(fakeDfPath) { + return path.join(fakeDfPath, process.platform === "win32" ? "df.cmd" : "df"); +} + +async function writeDiskPreflightDfCommand(directory) { + const fakeBin = path.join(directory, "fake-disk-preflight-bin"); + const dfScript = path.join(fakeBin, "df"); + const stateFile = path.join(directory, "fake-disk-preflight-df-state.txt"); + + await mkdir(fakeBin, { recursive: true }); + await writeFile( + dfScript, + `#!/usr/bin/env node +import { existsSync, readFileSync, writeFileSync } from "node:fs"; + +const stateFile = ${JSON.stringify(stateFile)}; +const previousRuns = existsSync(stateFile) ? Number(readFileSync(stateFile, "utf8")) : 0; +writeFileSync(stateFile, String(previousRuns + 1)); + +console.log("Filesystem 1024-blocks Used Available Capacity Mounted on"); +if (previousRuns < 2) { + console.log("rakkr-smoke 1000 900 100 90% /"); +} else { + console.log("rakkr-smoke 1000 100 900 10% /"); +} +`, + ); + + if (process.platform === "win32") { + await writeFile( + path.join(fakeBin, "df.cmd"), + `@echo off\r\n"${process.execPath}" "${dfScript}" %*\r\n`, + ); + } else { + await chmod(dfScript, 0o755); + } + + return fakeBin; +} + +async function writeRuntimeDiskRecoveryDfCommand(directory, rawCapturePath, staleCachePath) { + const fakeBin = path.join(directory, "fake-runtime-disk-recovery-bin"); + const dfScript = path.join(fakeBin, "df"); + + await mkdir(fakeBin, { recursive: true }); + await writeFile( + dfScript, + `#!/usr/bin/env node +import { existsSync } from "node:fs"; + +const rawCapturePath = ${JSON.stringify(rawCapturePath)}; +const staleCachePath = ${JSON.stringify(staleCachePath)}; + +// Report the disk as full only while a capture is in flight (the raw capture +// file exists) and the stale cache entry has not yet been swept away. Keying the +// low reading to filesystem state -- rather than a one-shot counter -- keeps the +// system-health monitor and the recording-job disk monitor observing the same +// condition, so the runtime disk-recovery path fires deterministically no matter +// which loop polls df first. The reading flips back to healthy the moment the +// recovery sweep deletes the stale cache files. +console.log("Filesystem 1024-blocks Used Available Capacity Mounted on"); +if (existsSync(rawCapturePath) && existsSync(staleCachePath)) { + console.log("rakkr-smoke 10000 9999 1 99% /"); +} else { + console.log("rakkr-smoke 10000 100 9900 1% /"); +} +`, + ); + + if (process.platform === "win32") { + await writeFile( + path.join(fakeBin, "df.cmd"), + `@echo off\r\n"${process.execPath}" "${dfScript}" %*\r\n`, + ); + } else { + await chmod(dfScript, 0o755); + } + + return fakeBin; +} diff --git a/scripts/agent-fake-controller-smoke-recovery.mjs b/scripts/agent-fake-controller-smoke-recovery.mjs index c3a8241..98d3a6f 100644 --- a/scripts/agent-fake-controller-smoke-recovery.mjs +++ b/scripts/agent-fake-controller-smoke-recovery.mjs @@ -1,19 +1,15 @@ -import { existsSync, readFileSync } from "node:fs"; -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { spawnDaemonAgent } from "./agent-fake-controller-smoke-agent.mjs"; import { - writeDeviceAssertCaptureCommand, - writeRenumberedAudioInventoryFixtures, - writeRecoverableRestartCaptureFile, -} from "./agent-fake-controller-smoke-support.mjs"; -import { - fileExists, - invariant, - readJsonLines, - waitFor, -} from "./agent-fake-controller-smoke-utils.mjs"; + agentStateStatus, + runCaptureDeviceRenumberingScenario, + runDiskPreflightRecoveryScenario, + runRuntimeDiskRecoveryScenario, +} from "./agent-fake-controller-smoke-recovery-disk.mjs"; +import { writeRecoverableRestartCaptureFile } from "./agent-fake-controller-smoke-support.mjs"; +import { fileExists, invariant, readJsonLines, waitFor } from "./agent-fake-controller-smoke-utils.mjs"; export async function runUploadBoundaryRecoveryScenarios(deps) { await runRestartRecoveryScenario(deps); @@ -479,483 +475,9 @@ async function runUploadedRecoveryScenario({ setActiveScenario(undefined); } -async function runDiskPreflightRecoveryScenario({ - address, - agentContext, - captureCommand, - createJob, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, -}) { - const stateFile = path.join(smokeRoot, "disk-preflight-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "disk-preflight-recovery-health-events.jsonl"); - const manifestFile = path.join(smokeRoot, "disk-preflight-recovery-manifest.json"); - const cacheRoot = path.join(smokeRoot, "data", "recordings", "local-captures"); - const staleRawPath = path.join(cacheRoot, "rec_fake_controller_stale.raw.wav"); - const staleOutputPath = path.join(cacheRoot, "rec_fake_controller_stale.mp3"); - const fakeDfPath = await writeDiskPreflightDfCommand(smokeRoot); - const retention = { - deleteAfterUpload: false, - maxAgeDays: null, - maxBytes: null, - minFreeDiskPercent: 95, - policyId: "retention-disk-preflight-recovery-smoke", - }; - const job = createJob({ - captureChannels: 4, - durationSeconds: 2, - expectSuccess: true, - jobId: "job_fake_controller_disk_preflight_recovery", - name: "disk-preflight-recovery", - outputFileName: "rec_fake_controller_disk_preflight_recovery.mp3", - recordingId: "rec_fake_controller_disk_preflight_recovery", - recorderCacheRetention: retention, - }); - const observed = createObserved(); - - await mkdir(cacheRoot, { recursive: true }); - await writeFile(staleRawPath, Buffer.alloc(128, 1)); - await writeFile(staleOutputPath, Buffer.alloc(256, 2)); - await writeFile( - manifestFile, - JSON.stringify( - { - entries: [ - { - outputPath: staleOutputPath, - policyId: retention.policyId, - rawOutputPath: staleRawPath, - recordingId: "rec_fake_controller_stale", - }, - ], - version: 1, - }, - null, - 2, - ), - ); - - setActiveScenario({ - job, - jobs: [job], - observed, - scenario: { - expectSuccess: true, - name: "disk-preflight-recovery", - recorderCachePolicySequence: [[], [retention]], - }, - }); - - const child = spawnDaemonAgent( - agentContext, - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - { - PATH: `${fakeDfPath}${path.delimiter}${process.env.PATH ?? ""}`, - }, - [ - "--recorder-cache-manifest-file", - manifestFile, - "--system-health-df-command", - fakeDfCommandPath(fakeDfPath), - ], - ); - - try { - await waitFor( - () => - job.status === "completed" && - observed.cacheUpload && - observed.healthEvents.some( - (event) => event.type === "agent.recording_job.disk_space_recovered", - ) && - agentStateStatus(stateFile) === "completed", - 30_000, - () => - `job=${job.status} upload=${Boolean(observed.cacheUpload)} state=${agentStateStatus(stateFile) ?? ""} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const state = JSON.parse(await readFile(stateFile, "utf8")); - const healthLogEvents = await readJsonLines(healthLogFile); - const cleanupEvent = healthLogEvents.find( - (event) => event.type === "agent.recording_job.disk_space_cleanup_attempted", - ); - const recoveredEvent = healthLogEvents.find( - (event) => event.type === "agent.recording_job.disk_space_recovered", - ); - - invariant(job.status === "completed", "disk preflight recovery did not complete the job"); - invariant(state.status === "completed", "disk preflight recovery state did not complete"); - invariant(observed.cacheUploads.length === 1, "disk preflight recovery did not upload once"); - invariant(cleanupEvent?.details?.deleted === 1, "disk preflight cleanup did not delete cache"); - invariant( - cleanupEvent.details?.items?.[0]?.reason === "min_free_disk", - "disk preflight cleanup did not use min-free policy", - ); - invariant( - recoveredEvent?.details?.freeBytes > recoveredEvent?.details?.initialFreeBytes, - "disk preflight recovery did not record improved free space", - ); - invariant(!(await fileExists(staleRawPath)), "disk preflight cleanup left stale raw cache"); - invariant(!(await fileExists(staleOutputPath)), "disk preflight cleanup left stale output cache"); - setActiveScenario(undefined); -} - -async function runRuntimeDiskRecoveryScenario({ - address, - agentContext, - captureCommand, - createJob, - createObserved, - nodeId: _nodeId, - renderCommand, - setActiveScenario, - smokeRoot, -}) { - const stateFile = path.join(smokeRoot, "runtime-disk-recovery-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "runtime-disk-recovery-health-events.jsonl"); - const manifestFile = path.join(smokeRoot, "runtime-disk-recovery-manifest.json"); - const cacheRoot = path.join(smokeRoot, "data", "recordings", "local-captures"); - const rawCapturePath = path.join( - cacheRoot, - "rec_fake_controller_runtime_disk_recovery.raw.wav", - ); - const staleRawPath = path.join(cacheRoot, "rec_fake_controller_runtime_stale.raw.wav"); - const staleOutputPath = path.join(cacheRoot, "rec_fake_controller_runtime_stale.mp3"); - const fakeDfPath = await writeRuntimeDiskRecoveryDfCommand( - smokeRoot, - rawCapturePath, - staleOutputPath, - ); - const retention = { - deleteAfterUpload: false, - maxAgeDays: null, - maxBytes: null, - minFreeDiskPercent: 95, - policyId: "retention-runtime-disk-recovery-smoke", - }; - const job = createJob({ - durationSeconds: 2, - expectSuccess: true, - jobId: "job_fake_controller_runtime_disk_recovery", - name: "runtime-disk-recovery", - outputFileName: "rec_fake_controller_runtime_disk_recovery.mp3", - recordingId: "rec_fake_controller_runtime_disk_recovery", - recorderCacheRetention: retention, - }); - const observed = createObserved(); - - await mkdir(cacheRoot, { recursive: true }); - await writeFile(staleRawPath, Buffer.alloc(128, 3)); - await writeFile(staleOutputPath, Buffer.alloc(256, 4)); - await writeFile( - manifestFile, - JSON.stringify( - { - entries: [ - { - outputPath: staleOutputPath, - policyId: retention.policyId, - rawOutputPath: staleRawPath, - recordingId: "rec_fake_controller_runtime_stale", - }, - ], - version: 1, - }, - null, - 2, - ), - ); - - setActiveScenario({ - job, - jobs: [job], - observed, - scenario: { - expectSuccess: true, - name: "runtime-disk-recovery", - recorderCachePolicies: [retention], - }, - }); - - const child = spawnDaemonAgent( - agentContext, - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - { - PATH: `${fakeDfPath}${path.delimiter}${process.env.PATH ?? ""}`, - }, - [ - "--recorder-cache-manifest-file", - manifestFile, - "--system-health-df-command", - fakeDfCommandPath(fakeDfPath), - ], - ); - - try { - await waitFor( - () => - job.status === "completed" && - observed.cacheUpload && - observed.healthEvents.some( - (event) => event.type === "agent.recording_job.disk_space_runtime_recovered", - ) && - observed.healthEvents.some( - (event) => event.type === "agent.recording_job.capture_segments_stitched", - ) && - agentStateStatus(stateFile) === "completed", - 30_000, - () => - `job=${job.status} upload=${Boolean(observed.cacheUpload)} state=${agentStateStatus(stateFile) ?? ""} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const state = JSON.parse(await readFile(stateFile, "utf8")); - const healthLogEvents = await readJsonLines(healthLogFile); - const exhaustedEvent = healthLogEvents.find( - (event) => event.type === "agent.recording_job.disk_space_exhausted", - ); - const runtimeRecoveredEvent = healthLogEvents.find( - (event) => event.type === "agent.recording_job.disk_space_runtime_recovered", - ); - const stitchedEvent = healthLogEvents.find( - (event) => event.type === "agent.recording_job.capture_segments_stitched", - ); - - invariant(job.status === "completed", "runtime disk recovery did not complete the job"); - invariant(state.status === "completed", "runtime disk recovery state did not complete"); - invariant(observed.failures === 0, "runtime disk recovery should not mark the job failed"); - invariant(observed.cacheUploads.length === 1, "runtime disk recovery did not upload once"); - invariant(exhaustedEvent, "runtime disk recovery did not log disk exhaustion"); - invariant( - exhaustedEvent.severity === "warning", - "recoverable runtime disk exhaustion was not warning severity", - ); - invariant( - exhaustedEvent.details?.willRetry === true, - "runtime disk exhaustion did not record retry intent", - ); - invariant(runtimeRecoveredEvent, "runtime disk recovery did not log recovery"); - invariant( - runtimeRecoveredEvent.details?.requiredBytes >= runtimeRecoveredEvent.details?.remainingBytes, - "runtime disk recovery did not preserve required byte evidence", - ); - invariant(stitchedEvent, "runtime disk recovery did not stitch the preserved segment"); - invariant(stitchedEvent.details?.segmentCount === 1, "runtime disk recovery lost segment count"); - invariant(!(await fileExists(staleRawPath)), "runtime disk cleanup left stale raw cache"); - invariant(!(await fileExists(staleOutputPath)), "runtime disk cleanup left stale output cache"); - setActiveScenario(undefined); -} - -async function runCaptureDeviceRenumberingScenario({ - address, - agentContext, - createJob, - createObserved, - renderCommand, - setActiveScenario, - smokeRoot, -}) { - const stateFile = path.join(smokeRoot, "capture-device-renumbering-agent-state.json"); - const healthLogFile = path.join(smokeRoot, "capture-device-renumbering-health-events.jsonl"); - const captureCommand = await writeDeviceAssertCaptureCommand( - smokeRoot, - "hw:CARD=SMOKE,DEV=0", - ); - const inventory = await writeRenumberedAudioInventoryFixtures(smokeRoot); - const job = createJob({ - captureInterfaceId: "alsa_card_smoke_dev_0", - expectSuccess: true, - jobId: "job_fake_controller_capture_device_renumbering", - name: "capture-device-renumbering", - outputFileName: "rec_fake_controller_capture_device_renumbering.mp3", - recordingId: "rec_fake_controller_capture_device_renumbering", - }); - const observed = createObserved(); - job.command.captureDevice = "hw:2,0"; - setActiveScenario({ - job, - jobs: [job], - observed, - scenario: { - expectSuccess: true, - name: "capture-device-renumbering", - }, - }); - - const child = spawnDaemonAgent( - agentContext, - address, - captureCommand, - healthLogFile, - renderCommand, - stateFile, - { - PATH: `${inventory.fakeArecordPath}${path.delimiter}${process.env.PATH ?? ""}`, - }, - [ - "--inventory-arecord-command", - inventory.fakeArecordCommand, - "--inventory-proc-asound-pcm-path", - inventory.fakeProcAsoundPcmPath, - ], - ); - - try { - await waitFor( - () => - job.status === "completed" && - observed.cacheUpload && - observed.healthEvents.some( - (event) => event.type === "agent.recording_job.capture_device_refreshed", - ) && - agentStateStatus(stateFile) === "completed", - 30_000, - () => - `job=${job.status} upload=${Boolean(observed.cacheUpload)} state=${agentStateStatus(stateFile) ?? ""} health=${observed.healthEvents.map((event) => event.type).join(",")}`, - ); - } finally { - child.kill(); - await child.closed; - } - - const state = JSON.parse(await readFile(stateFile, "utf8")); - const healthLogEvents = await readJsonLines(healthLogFile); - const refreshEvent = healthLogEvents.find( - (event) => event.type === "agent.recording_job.capture_device_refreshed", - ); - - invariant(job.status === "completed", "capture device renumbering did not complete the job"); - invariant(state.status === "completed", "capture device renumbering state did not complete"); - invariant(observed.cacheUploads.length === 1, "capture device renumbering did not upload once"); - invariant(refreshEvent, "capture device renumbering did not log refresh"); - invariant( - refreshEvent.details?.captureInterfaceId === "alsa_card_smoke_dev_0", - "capture device renumbering used the wrong stable interface id", - ); - invariant( - refreshEvent.details?.previousDevice === "hw:2,0", - "capture device renumbering did not record the stale device", - ); - invariant( - refreshEvent.details?.refreshedDevice === "hw:CARD=SMOKE,DEV=0", - "capture device renumbering did not record the refreshed device", - ); - setActiveScenario(undefined); -} - -function agentStateStatus(stateFile) { - if (!existsSync(stateFile)) { - return undefined; - } - - try { - return JSON.parse(readFileSync(stateFile, "utf8")).status; - } catch { - return undefined; - } -} - async function writeAgentState(stateFile, state) { await writeFile( stateFile, JSON.stringify({ ...state, updatedAt: "2026-06-25T00:00:00Z" }, null, 2), ); } - -function fakeDfCommandPath(fakeDfPath) { - return path.join(fakeDfPath, process.platform === "win32" ? "df.cmd" : "df"); -} - -async function writeDiskPreflightDfCommand(directory) { - const fakeBin = path.join(directory, "fake-disk-preflight-bin"); - const dfScript = path.join(fakeBin, "df"); - const stateFile = path.join(directory, "fake-disk-preflight-df-state.txt"); - - await mkdir(fakeBin, { recursive: true }); - await writeFile( - dfScript, - `#!/usr/bin/env node -import { existsSync, readFileSync, writeFileSync } from "node:fs"; - -const stateFile = ${JSON.stringify(stateFile)}; -const previousRuns = existsSync(stateFile) ? Number(readFileSync(stateFile, "utf8")) : 0; -writeFileSync(stateFile, String(previousRuns + 1)); - -console.log("Filesystem 1024-blocks Used Available Capacity Mounted on"); -if (previousRuns < 2) { - console.log("rakkr-smoke 1000 900 100 90% /"); -} else { - console.log("rakkr-smoke 1000 100 900 10% /"); -} -`, - ); - - if (process.platform === "win32") { - await writeFile( - path.join(fakeBin, "df.cmd"), - `@echo off\r\n"${process.execPath}" "${dfScript}" %*\r\n`, - ); - } else { - await chmod(dfScript, 0o755); - } - - return fakeBin; -} - -async function writeRuntimeDiskRecoveryDfCommand(directory, rawCapturePath, staleCachePath) { - const fakeBin = path.join(directory, "fake-runtime-disk-recovery-bin"); - const dfScript = path.join(fakeBin, "df"); - - await mkdir(fakeBin, { recursive: true }); - await writeFile( - dfScript, - `#!/usr/bin/env node -import { existsSync } from "node:fs"; - -const rawCapturePath = ${JSON.stringify(rawCapturePath)}; -const staleCachePath = ${JSON.stringify(staleCachePath)}; - -// Report the disk as full only while a capture is in flight (the raw capture -// file exists) and the stale cache entry has not yet been swept away. Keying the -// low reading to filesystem state -- rather than a one-shot counter -- keeps the -// system-health monitor and the recording-job disk monitor observing the same -// condition, so the runtime disk-recovery path fires deterministically no matter -// which loop polls df first. The reading flips back to healthy the moment the -// recovery sweep deletes the stale cache files. -console.log("Filesystem 1024-blocks Used Available Capacity Mounted on"); -if (existsSync(rawCapturePath) && existsSync(staleCachePath)) { - console.log("rakkr-smoke 10000 9999 1 99% /"); -} else { - console.log("rakkr-smoke 10000 100 9900 1% /"); -} -`, - ); - - if (process.platform === "win32") { - await writeFile( - path.join(fakeBin, "df.cmd"), - `@echo off\r\n"${process.execPath}" "${dfScript}" %*\r\n`, - ); - } else { - await chmod(dfScript, 0o755); - } - - return fakeBin; -} diff --git a/scripts/agent-fake-controller-smoke-scenario-helpers.mjs b/scripts/agent-fake-controller-smoke-scenario-helpers.mjs new file mode 100644 index 0000000..403e3fc --- /dev/null +++ b/scripts/agent-fake-controller-smoke-scenario-helpers.mjs @@ -0,0 +1,143 @@ +import path from "node:path"; + +import { readJsonLines } from "./agent-fake-controller-smoke-utils.mjs"; + +// Fresh controller-observed counters for one scenario run. +export function createObserved() { + return { + cancelReason: undefined, + cancellations: 0, + cacheUpload: undefined, + cacheUploads: [], + channelMapReads: 0, + channelMapFailures: 0, + claimNextReads: 0, + claims: 0, + configReads: 0, + failureReason: undefined, + failures: 0, + heartbeats: 0, + healthEvents: [], + inventoryReconciles: 0, + jobStatusReads: 0, + jobStatusReadFailures: 0, + claimNextReadFailures: 0, + jobHeartbeatFailures: 0, + maxRunningJobs: 0, + meterFrames: 0, + monitorChunkFailures: 0, + monitorChunks: [], + nextReads: 0, + nodeConfigFailures: 0, + nodeHeartbeatFailures: 0, + nodeHeartbeats: 0, + }; +} + +// Bind the smoke node id so `createJob(scenario)` keeps its single-argument shape +// (the recovery scenarios receive `createJob` in their deps and call it that way). +export function makeCreateJob(nodeId) { + return function createJob(scenario) { + return { + command: { + captureChannels: scenario.captureChannels ?? 1, + captureDevice: "fake-device", + captureFormat: "S16_LE", + captureInterfaceId: scenario.captureInterfaceId ?? null, + captureSampleRate: 48000, + channelMap: scenario.channelMap ?? null, + durationSeconds: scenario.durationSeconds ?? 1, + outputBitrateKbps: 128, + outputCodec: "mp3", + outputFileName: scenario.outputFileName, + outputVbr: true, + recorderCacheRetention: scenario.recorderCacheRetention ?? immediateRetention(), + type: "alsa_capture", + }, + failureReason: undefined, + id: scenario.jobId, + nodeId, + recordingId: scenario.recordingId, + status: "queued", + }; + }; +} + +function immediateRetention() { + return { + deleteAfterUpload: true, + maxAgeDays: null, + maxBytes: null, + minFreeDiskPercent: null, + policyId: "retention-recorder-cache-smoke", + }; +} + +export function deferredSweepRetention() { + return { + deleteAfterUpload: false, + maxAgeDays: null, + maxBytes: 1, + minFreeDiskPercent: null, + policyId: "retention-recorder-cache-sweep-smoke", + }; +} + +export function minFreeSweepRetention() { + return { + deleteAfterUpload: false, + maxAgeDays: null, + maxBytes: null, + minFreeDiskPercent: 95, + policyId: "retention-recorder-cache-min-free-smoke", + }; +} + +export function recorderCachePoliciesForScenario(scenario) { + if (scenario.recorderCachePolicySequence) { + const index = Math.min( + scenario.recorderCachePolicySequenceIndex ?? 0, + scenario.recorderCachePolicySequence.length - 1, + ); + scenario.recorderCachePolicySequenceIndex = index + 1; + + return scenario.recorderCachePolicySequence[index]; + } + + if (scenario.recorderCachePolicies) { + return scenario.recorderCachePolicies; + } + + if (scenario.deferredSweep) { + return [deferredSweepRetention()]; + } + + if (scenario.minFreeSweep) { + return [minFreeSweepRetention()]; + } + + return []; +} + +export function fakeDfCommandPath(fakeDfPath) { + return path.join(fakeDfPath, process.platform === "win32" ? "df.cmd" : "df"); +} + +// Extra diagnostics folded into a daemon-scenario timeout error: the synced and +// local health-event streams plus the controller-observed counters, so a flake +// shows which job stalled and how far it got — not just the unmet predicate. +export async function daemonScenarioDiagnostics(observed, healthLogFile) { + const localHealth = await readJsonLines(healthLogFile); + + return ( + `\n--- synced health events (${observed.healthEvents.length}) ---\n` + + `${observed.healthEvents.map((event) => event.type).join(",")}\n` + + `--- local health log (${localHealth.length}) ---\n` + + `${localHealth.map((event) => event.type).join(",")}\n` + + `--- observed ---\n` + + `claims=${observed.claims} claimNextReads=${observed.claimNextReads} ` + + `uploads=${observed.cacheUploads.length} configReads=${observed.configReads} ` + + `heartbeats=${observed.heartbeats} jobStatusReads=${observed.jobStatusReads} ` + + `failures=${observed.failures} maxRunningJobs=${observed.maxRunningJobs}` + ); +} diff --git a/scripts/agent-fake-controller-smoke-support.mjs b/scripts/agent-fake-controller-smoke-support.mjs index d18d0e3..a6b904f 100644 --- a/scripts/agent-fake-controller-smoke-support.mjs +++ b/scripts/agent-fake-controller-smoke-support.mjs @@ -181,6 +181,15 @@ export async function writeFakeCaptureCommand(directory) { return writeFakeCaptureCommandScript(directory, "fake-capture", false); } +// A capture that lingers well past one job-poll cycle so the recording-job disk +// monitor reliably polls df at least once while the capture is still in flight. +// The default 750 ms capture can, on a slow runner, be spawned and exit inside a +// single 1 s poll interval — the monitor then never observes the runtime +// disk-exhaustion window and the runtime-recovery path silently does not fire. +export async function writeFakeRuntimeDiskRecoveryCaptureCommand(directory) { + return writeFakeCaptureCommandScript(directory, "fake-runtime-disk-capture", false, 3500); +} + export async function writeDeviceAssertCaptureCommand(directory, expectedDevice) { const captureScript = path.join(directory, "fake-device-assert-capture.mjs"); await writeFile( @@ -608,7 +617,12 @@ process.exit(1); return meterScript; } -async function writeFakeCaptureCommandScript(directory, commandName, requireTemplateOutputFlag) { +async function writeFakeCaptureCommandScript( + directory, + commandName, + requireTemplateOutputFlag, + lingerMs = 750, +) { const captureScript = path.join(directory, `${commandName}.mjs`); await writeFile( captureScript, @@ -632,7 +646,7 @@ if (${JSON.stringify(requireTemplateOutputFlag)} && outputFlagIndex < 0) { mkdirSync(path.dirname(outputPath), { recursive: true }); writeFileSync(outputPath, wavFile(channels, [0, 12000, -12000, 6000, -6000, 3000])); -await new Promise((resolve) => setTimeout(resolve, 750)); +await new Promise((resolve) => setTimeout(resolve, ${lingerMs})); function optionNumber(flags, fallback) { for (const flag of flags) { diff --git a/scripts/agent-fake-controller-smoke.mjs b/scripts/agent-fake-controller-smoke.mjs index d7f3fc8..de5e993 100644 --- a/scripts/agent-fake-controller-smoke.mjs +++ b/scripts/agent-fake-controller-smoke.mjs @@ -76,12 +76,22 @@ import { waitForDaemon, } from "./agent-fake-controller-smoke-utils.mjs"; import { createFakeControllerHandler } from "./agent-fake-controller-smoke-controller.mjs"; +import { + createObserved, + daemonScenarioDiagnostics, + deferredSweepRetention, + fakeDfCommandPath, + makeCreateJob, + minFreeSweepRetention, + recorderCachePoliciesForScenario, +} from "./agent-fake-controller-smoke-scenario-helpers.mjs"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, ".."); const smokeRoot = await mkdtemp(path.join(tmpdir(), "rakkr-agent-fake-controller-")); const nodeId = "node_fake_controller_smoke"; const token = "node-token"; const agentContext = { nodeId, repoRoot, smokeRoot, token }; +const createJob = makeCreateJob(nodeId); const scenarios = [ { cacheUploadFails: false, @@ -724,87 +734,6 @@ function assertControllerStopScenario({ healthLogEvents, job, observed, scenario ); } -function createJob(scenario) { - return { - command: { - captureChannels: scenario.captureChannels ?? 1, - captureDevice: "fake-device", - captureFormat: "S16_LE", - captureInterfaceId: scenario.captureInterfaceId ?? null, - captureSampleRate: 48000, - channelMap: scenario.channelMap ?? null, - durationSeconds: scenario.durationSeconds ?? 1, - outputBitrateKbps: 128, - outputCodec: "mp3", - outputFileName: scenario.outputFileName, - outputVbr: true, - recorderCacheRetention: scenario.recorderCacheRetention ?? immediateRetention(), - type: "alsa_capture", - }, - failureReason: undefined, - id: scenario.jobId, - nodeId, - recordingId: scenario.recordingId, - status: "queued", - }; -} - -function immediateRetention() { - return { - deleteAfterUpload: true, - maxAgeDays: null, - maxBytes: null, - minFreeDiskPercent: null, - policyId: "retention-recorder-cache-smoke", - }; -} - -function deferredSweepRetention() { - return { - deleteAfterUpload: false, - maxAgeDays: null, - maxBytes: 1, - minFreeDiskPercent: null, - policyId: "retention-recorder-cache-sweep-smoke", - }; -} - -function minFreeSweepRetention() { - return { - deleteAfterUpload: false, - maxAgeDays: null, - maxBytes: null, - minFreeDiskPercent: 95, - policyId: "retention-recorder-cache-min-free-smoke", - }; -} - -function recorderCachePoliciesForScenario(scenario) { - if (scenario.recorderCachePolicySequence) { - const index = Math.min( - scenario.recorderCachePolicySequenceIndex ?? 0, - scenario.recorderCachePolicySequence.length - 1, - ); - scenario.recorderCachePolicySequenceIndex = index + 1; - - return scenario.recorderCachePolicySequence[index]; - } - - if (scenario.recorderCachePolicies) { - return scenario.recorderCachePolicies; - } - - if (scenario.deferredSweep) { - return [deferredSweepRetention()]; - } - - if (scenario.minFreeSweep) { - return [minFreeSweepRetention()]; - } - - return []; -} - async function assertLocalRecorderCacheDeleted(outputFileName) { const [renderedPath, rawPath] = localRecorderCachePaths(smokeRoot, outputFileName); invariant(!(await fileExists(renderedPath)), `rendered recorder cache remains: ${renderedPath}`); @@ -822,29 +751,6 @@ async function assertAnyLocalRecorderCacheDeleted(outputFileNames) { throw new Error("min-free recorder-cache sweep did not remove any local cache files"); } -function fakeDfCommandPath(fakeDfPath) { - return path.join(fakeDfPath, process.platform === "win32" ? "df.cmd" : "df"); -} - -// Extra diagnostics folded into a daemon-scenario timeout error: the synced and -// local health-event streams plus the controller-observed counters, so a flake -// shows which job stalled and how far it got — not just the unmet predicate. -async function daemonScenarioDiagnostics(observed, healthLogFile) { - const localHealth = await readJsonLines(healthLogFile); - - return ( - `\n--- synced health events (${observed.healthEvents.length}) ---\n` + - `${observed.healthEvents.map((event) => event.type).join(",")}\n` + - `--- local health log (${localHealth.length}) ---\n` + - `${localHealth.map((event) => event.type).join(",")}\n` + - `--- observed ---\n` + - `claims=${observed.claims} claimNextReads=${observed.claimNextReads} ` + - `uploads=${observed.cacheUploads.length} configReads=${observed.configReads} ` + - `heartbeats=${observed.heartbeats} jobStatusReads=${observed.jobStatusReads} ` + - `failures=${observed.failures} maxRunningJobs=${observed.maxRunningJobs}` - ); -} - function healthScenarioDeps(overrides) { return { ...overrides, @@ -891,34 +797,3 @@ function jobScenarioDeps(overrides) { function setActiveScenario(scenario) { activeScenario = scenario; } - -function createObserved() { - return { - cancelReason: undefined, - cancellations: 0, - cacheUpload: undefined, - cacheUploads: [], - channelMapReads: 0, - channelMapFailures: 0, - claimNextReads: 0, - claims: 0, - configReads: 0, - failureReason: undefined, - failures: 0, - heartbeats: 0, - healthEvents: [], - inventoryReconciles: 0, - jobStatusReads: 0, - jobStatusReadFailures: 0, - claimNextReadFailures: 0, - jobHeartbeatFailures: 0, - maxRunningJobs: 0, - meterFrames: 0, - monitorChunkFailures: 0, - monitorChunks: [], - nextReads: 0, - nodeConfigFailures: 0, - nodeHeartbeatFailures: 0, - nodeHeartbeats: 0, - }; -} diff --git a/scripts/verify-first-reliable-recording-baseline.mjs b/scripts/verify-first-reliable-recording-baseline.mjs index 3a71597..86a6cbf 100644 --- a/scripts/verify-first-reliable-recording-baseline.mjs +++ b/scripts/verify-first-reliable-recording-baseline.mjs @@ -67,6 +67,11 @@ const sourceFiles = [ "scripts/agent-fake-controller-smoke-recovery.mjs", "scripts/agent-fake-controller-smoke-support.mjs", "scripts/agent-fake-controller-smoke.mjs", + "scripts/agent-fake-controller-smoke-recovery-disk.mjs", + "crates/recorder-agent/src/inventory/alsa.rs", + "apps/api/test/agent-routes-recording-lifecycle.test.ts", + "apps/api/test/recording-job-export-read.test.ts", + "apps/api/test/recording-job-export-lifecycle.test.ts", ]; const baselinePhrases = [ "MVP baseline checked", diff --git a/scripts/verify-generic-device-baseline.mjs b/scripts/verify-generic-device-baseline.mjs index a77db9a..ae34479 100644 --- a/scripts/verify-generic-device-baseline.mjs +++ b/scripts/verify-generic-device-baseline.mjs @@ -33,6 +33,8 @@ const sourceFiles = [ "crates/recorder-agent/src/meter_command.rs", "crates/recorder-agent/src/node_config.rs", "crates/recorder-agent/src/inventory.rs", + "crates/recorder-agent/src/inventory/alsa.rs", + "crates/recorder-agent/src/inventory/runtime.rs", "crates/recorder-agent/src/telemetry.rs", "crates/recorder-agent/src/telemetry/correlation.rs", "crates/recorder-agent/src/channel_map.rs", diff --git a/scripts/verify-health-watchdog-baseline.mjs b/scripts/verify-health-watchdog-baseline.mjs index 6d70025..904b283 100644 --- a/scripts/verify-health-watchdog-baseline.mjs +++ b/scripts/verify-health-watchdog-baseline.mjs @@ -36,6 +36,7 @@ const sourceFiles = [ "crates/recorder-agent/src/main/health_tick.rs", "crates/recorder-agent/src/config.rs", "crates/recorder-agent/src/inventory.rs", + "crates/recorder-agent/src/inventory/alsa.rs", "crates/recorder-agent/src/system_health.rs", "crates/recorder-agent/src/health_log.rs", "crates/recorder-agent/src/telemetry.rs", @@ -62,8 +63,11 @@ const sourceFiles = [ "scripts/agent-fake-controller-smoke-agent.mjs", "scripts/agent-fake-controller-smoke-controller.mjs", "scripts/agent-fake-controller-smoke-health.mjs", + "scripts/agent-fake-controller-smoke-health-system.mjs", + "scripts/agent-fake-controller-smoke-health-meters.mjs", "scripts/agent-fake-controller-smoke-jobs.mjs", "scripts/agent-fake-controller-smoke-recovery.mjs", + "scripts/agent-fake-controller-smoke-recovery-disk.mjs", "scripts/agent-fake-controller-smoke-support.mjs", "scripts/agent-fake-controller-smoke.mjs", "apps/api/test/watchdog-runner.test.ts", diff --git a/scripts/verify-node-lifecycle-baseline.mjs b/scripts/verify-node-lifecycle-baseline.mjs index ebef6c7..2ea3c45 100644 --- a/scripts/verify-node-lifecycle-baseline.mjs +++ b/scripts/verify-node-lifecycle-baseline.mjs @@ -4,6 +4,7 @@ const baselineFile = "docs/internal/baselines/NODE_LIFECYCLE_BASELINE.md"; const sourceFiles = [ "packages/shared/src/agent-version.ts", "packages/shared/src/index.ts", + "packages/shared/src/nodes.ts", "apps/api/src/node-lifecycle.ts", "apps/api/src/node-lifecycle-routes.ts", "apps/api/src/node-liveness.ts", diff --git a/scripts/verify-operations-baseline.mjs b/scripts/verify-operations-baseline.mjs index 41a3869..4adb8f4 100644 --- a/scripts/verify-operations-baseline.mjs +++ b/scripts/verify-operations-baseline.mjs @@ -26,13 +26,16 @@ const sourceFiles = [ "apps/web/src/components/upload-policy-panel.tsx", "apps/web/src/components/upload-runner-panel.tsx", "apps/api/test/recording-routes.test.ts", + "apps/api/test/recording-routes-metadata.test.ts", "apps/api/test/recording-export-routes.test.ts", "apps/api/test/recording-metadata-routes.test.ts", "apps/api/test/settings-routes.test.ts", + "apps/api/test/settings-routes-mutations.test.ts", "apps/api/test/audit-routes.test.ts", "apps/api/test/upload-policies.test.ts", "apps/api/test/upload-destinations.test.ts", "apps/api/test/upload-runner.test.ts", + "apps/api/test/upload-runner-routes.test.ts", "apps/api/test/recording-upload-queue-routes.test.ts", "apps/web/src/lib/recording-page-helpers.test.ts", "apps/web/src/lib/settings-page-helpers.test.ts", diff --git a/scripts/verify-rbac-audit-baseline.mjs b/scripts/verify-rbac-audit-baseline.mjs index 38ee896..8341d6d 100644 --- a/scripts/verify-rbac-audit-baseline.mjs +++ b/scripts/verify-rbac-audit-baseline.mjs @@ -2,7 +2,9 @@ import { access, readdir, readFile } from "node:fs/promises"; import path from "node:path"; const baselineFile = "docs/internal/baselines/RBAC_AUDIT_BASELINE.md"; -const sharedFile = "packages/shared/src/index.ts"; +// The permission catalog moved out of index.ts into its own domain module when +// the shared barrel was split; extract it from there. +const sharedFile = "packages/shared/src/rbac.ts"; const apiSourceDirectory = "apps/api/src"; const requiredPhrases = [ "Default deny", diff --git a/scripts/verify-recording-library-baseline.mjs b/scripts/verify-recording-library-baseline.mjs index b3eb20c..301a1bb 100644 --- a/scripts/verify-recording-library-baseline.mjs +++ b/scripts/verify-recording-library-baseline.mjs @@ -25,6 +25,9 @@ const sourceFiles = [ "apps/api/test/agent-routes.test.ts", "apps/api/test/schedule-runner.test.ts", "apps/web/src/lib/recording-page-helpers.test.ts", + "apps/api/test/recording-routes-metadata.test.ts", + "apps/api/test/recording-routes-delete.test.ts", + "apps/api/test/agent-routes-recording-lifecycle.test.ts", ]; const baselinePhrases = [ "RBAC-gated", diff --git a/scripts/verify-scheduler-baseline.mjs b/scripts/verify-scheduler-baseline.mjs index 1e771a1..2373adf 100644 --- a/scripts/verify-scheduler-baseline.mjs +++ b/scripts/verify-scheduler-baseline.mjs @@ -4,6 +4,7 @@ const baselineFile = "docs/internal/baselines/SCHEDULER_BASELINE.md"; const sourceFiles = [ "packages/shared/src/index.ts", "packages/db/src/schema.ts", + "packages/db/src/schema/schedules.ts", "packages/db/drizzle/0021_true_midnight.sql", "apps/api/src/schedule-engine.ts", "apps/api/src/schedule-routes.ts", @@ -15,6 +16,7 @@ const sourceFiles = [ "apps/api/test/schedule-engine.test.ts", "apps/api/test/schedule-runner.test.ts", "apps/api/test/schedule-routes.test.ts", + "apps/api/test/schedule-routes-lifecycle.test.ts", "apps/api/test/schedule-occurrence-routes.test.ts", "apps/web/src/lib/schedule-page-helpers.test.ts", "apps/web/src/lib/schedule-detail-page-helpers.test.ts", diff --git a/scripts/verify-settings-templates-baseline.mjs b/scripts/verify-settings-templates-baseline.mjs index 324a1a1..1c77d3c 100644 --- a/scripts/verify-settings-templates-baseline.mjs +++ b/scripts/verify-settings-templates-baseline.mjs @@ -64,6 +64,10 @@ const sourceFiles = [ "crates/recorder-agent/src/system_health.rs", "scripts/agent-fake-controller-smoke.mjs", "scripts/agent-fake-controller-smoke-support.mjs", + "packages/shared/src/channel-maps.ts", + "packages/shared/src/recordings.ts", + "packages/db/src/schema/schedules.ts", + "apps/api/test/settings-routes-mutations.test.ts", ]; const baselinePhrases = [ "checked baseline", diff --git a/scripts/verify-storage-upload-baseline.mjs b/scripts/verify-storage-upload-baseline.mjs index a19613b..de4b904 100644 --- a/scripts/verify-storage-upload-baseline.mjs +++ b/scripts/verify-storage-upload-baseline.mjs @@ -27,6 +27,7 @@ const sourceFiles = [ "apps/web/src/lib/upload-runner-panel-helpers.ts", "apps/api/test/upload-executor.test.ts", "apps/api/test/upload-runner.test.ts", + "apps/api/test/upload-runner-routes.test.ts", "apps/api/test/upload-destinations.test.ts", "apps/api/test/upload-policies.test.ts", "apps/api/test/upload-queue.test.ts", @@ -35,6 +36,7 @@ const sourceFiles = [ "apps/api/test/settings-routes.test.ts", "apps/api/test/metrics.test.ts", "apps/api/test/agent-routes.test.ts", + "apps/api/test/agent-routes-recording-lifecycle.test.ts", "apps/web/src/lib/upload-runner-panel-helpers.test.ts", ]; const baselinePhrases = [