Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4c5514a
Fix agent-release route shadowed by /nodes/:nodeId (audit G-CRIT)
yashau Jul 8, 2026
3baa062
Exclude provisioning nodes from reachable counts (audit N1/N2)
yashau Jul 8, 2026
6198711
Prefill scheduling defaults on calendar create (audit S1)
yashau Jul 8, 2026
a2383c3
Cover controller-settings default merge/clear on the in-memory store …
yashau Jul 8, 2026
83b2fe7
Lock provisioning/offline-liveness invariant in node-lifecycle baseli…
yashau Jul 8, 2026
26e89d2
Add 2026-07-08 gap-hunt audit ledger (Run 1)
yashau Jul 8, 2026
663798d
Re-clamp paginated lists when the total shrinks (audit H4-1)
yashau Jul 8, 2026
0257dbe
Finish stub-upload-policy removal from the console (audit H3-1, H3-2)
yashau Jul 8, 2026
f5b5f21
Stop agent :jobId route from shadowing /recording-jobs/export (audit …
yashau Jul 8, 2026
75a7ca6
Promote provisioning nodes on heartbeat; block self-un-promotion (aud…
yashau Jul 8, 2026
e42fb7e
Require a real destination when creating an upload policy (audit H3-3)
yashau Jul 8, 2026
16f6317
Offer provisioning in the node status filter (audit H1-2)
yashau Jul 8, 2026
dd8c991
Update gap-hunt ledger: Run 2 (7 fixes, streak 0)
yashau Jul 8, 2026
38bf6de
Cap and dedup schedule uploadPolicyIds (audit R3-4)
yashau Jul 8, 2026
d5b6813
Lock upload-policy update against clearing the destination (audit H3-…
yashau Jul 8, 2026
8444b71
Update gap-hunt ledger: Run 3 (2 changes, streak 0)
yashau Jul 8, 2026
c2f90f5
Update gap-hunt ledger: Run 4 clean (streak 1/5)
yashau Jul 8, 2026
ad20d3d
Update gap-hunt ledger: Run 5 clean (streak 2/5)
yashau Jul 8, 2026
63f8629
Stop cleared watchdog numeric fields from persisting 0 (audit H4-2 re…
yashau Jul 8, 2026
e25446a
Update gap-hunt ledger: Run 6 dirty, H4-2 re-triaged + fixed (streak …
yashau Jul 8, 2026
3e59bc4
Truncate over-cap heartbeat ipAddresses instead of failing closed (au…
yashau Jul 8, 2026
2c31abe
Update gap-hunt ledger: Run 7 dirty, R7-IPCAP heartbeat desync fixed …
yashau Jul 8, 2026
13c81a7
Keep a stale retention selection visible in the schedule form (audit …
yashau Jul 8, 2026
c2d1a4c
Update gap-hunt ledger: Run 8 dirty, R8-RETENTION-SELECT fixed (strea…
yashau Jul 8, 2026
4c88082
Update gap-hunt ledger: Run 9 clean (streak 1/5)
yashau Jul 8, 2026
e924e7b
Update gap-hunt ledger: Run 10 clean (streak 2/5)
yashau Jul 8, 2026
6facd9e
Update gap-hunt ledger: Run 11 clean (streak 3/5)
yashau Jul 8, 2026
77ea362
Update gap-hunt ledger: Run 12 clean (streak 4/5)
yashau Jul 8, 2026
9c9715a
Gap-hunt audit converged: 5 consecutive clean runs (Runs 9-13)
yashau Jul 8, 2026
91f4fe8
Handle catalogued audit entries: API hardening
yashau Jul 8, 2026
60ccf32
Handle catalogued audit entries: web console fixes
yashau Jul 8, 2026
d85277a
Cap recorder-agent ipAddresses at the documented heartbeat limit
yashau Jul 8, 2026
3b0aa84
Record post-convergence catalogued-entry cleanup in the gap-hunt ledger
yashau Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 110 additions & 13 deletions apps/api/src/agent-release-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ interface AgentReleaseServiceOptions {
const DEFAULT_TTL_MS = 30 * 60 * 1000;
const DEFAULT_ERROR_TTL_MS = 5 * 60 * 1000;
const DEFAULT_TIMEOUT_MS = 10_000;
// The newest agent release is normally on page 1 (GitHub lists newest-first), but
// a burst of docs/controller tags could push it back. Follow `Link: rel=next` a
// bounded number of pages so we still find it without unbounded paging (R N-3A).
const MAX_RELEASE_PAGES = 5;
// Never buffer an unbounded response body into memory. 100 releases/page of
// GitHub release JSON is well under this; a body larger than this from a
// misconfigured/hostile API URL is rejected rather than read (R N-3B).
const MAX_RELEASE_BODY_BYTES = 8 * 1024 * 1024;

export function createAgentReleaseService(
options: AgentReleaseServiceOptions = {},
Expand All @@ -72,22 +80,42 @@ export function createAgentReleaseService(

async function doFetch(): Promise<void> {
try {
const response = await fetchImpl(`${apiUrl}/repos/${repo}/releases?per_page=100`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "rakkr-controller",
"X-GitHub-Api-Version": "2022-11-28",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: AbortSignal.timeout(timeoutMs),
});
const firstUrl = `${apiUrl}/repos/${repo}/releases?per_page=100`;
const origin = new URL(firstUrl).origin;
const entries: unknown[] = [];
let url: string | null = firstUrl;

for (let page = 0; page < MAX_RELEASE_PAGES && url; page += 1) {
const response = await fetchImpl(url, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "rakkr-controller",
"X-GitHub-Api-Version": "2022-11-28",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: AbortSignal.timeout(timeoutMs),
});

if (!response.ok) {
throw new Error(`github_releases_${response.status}`);
}

const parsed = JSON.parse(
await readBoundedText(response, MAX_RELEASE_BODY_BYTES),
) as unknown;

if (Array.isArray(parsed)) {
entries.push(...parsed);
}

if (!response.ok) {
throw new Error(`github_releases_${response.status}`);
// Only follow a next link that stays on the same origin — don't chase a
// header pointing at an unrelated host.
const next = parseNextLink(response.headers.get("link"));

url = next && new URL(next).origin === origin ? next : null;
}

const body = (await response.json()) as unknown;
const resolved = resolveLatestAgentRelease(body);
const resolved = resolveLatestAgentRelease(entries);

if (resolved) {
release = resolved;
Expand Down Expand Up @@ -160,6 +188,75 @@ export function resolveLatestAgentRelease(body: unknown): AgentRelease | null {
return latest;
}

// Extracts the `rel="next"` URL from a GitHub `Link` header, or null when there
// is no next page. GitHub emits `<url>; rel="next", <url>; rel="last"`.
export function parseNextLink(linkHeader: string | null): string | null {
if (!linkHeader) {
return null;
}

for (const part of linkHeader.split(",")) {
const match = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part);

if (match) {
return match[1];
}
}

return null;
}

// Reads a response body as text but refuses to buffer more than `maxBytes`,
// checking the declared Content-Length first and then enforcing the cap while
// streaming (chunked responses omit Content-Length).
async function readBoundedText(response: Response, maxBytes: number): Promise<string> {
const declared = Number(response.headers.get("content-length"));

if (Number.isFinite(declared) && declared > maxBytes) {
throw new Error("github_releases_body_too_large");
}

const body = response.body;

if (!body) {
const text = await response.text();

if (byteLength(text) > maxBytes) {
throw new Error("github_releases_body_too_large");
}

return text;
}

const reader = body.getReader();
const decoder = new TextDecoder();
let received = 0;
let text = "";

for (;;) {
const { done, value } = await reader.read();

if (done) {
break;
}

received += value.byteLength;

if (received > maxBytes) {
await reader.cancel();
throw new Error("github_releases_body_too_large");
}

text += decoder.decode(value, { stream: true });
}

return text + decoder.decode();
}

function byteLength(value: string): number {
return new TextEncoder().encode(value).length;
}

let defaultService: AgentReleaseService | undefined;

export function agentReleaseService(): AgentReleaseService {
Expand Down
13 changes: 12 additions & 1 deletion apps/api/src/agent-route-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,18 @@ export const nodeHeartbeatSchema = z
.object({
agentVersion: z.string().trim().min(1).max(80),
hostname: z.string().trim().min(1).max(255),
ipAddresses: z.array(z.string().trim().min(1).max(120)).max(16).default([]),
// A heartbeat is liveness-critical, so an over-cap ipAddresses list must not
// fail the whole heartbeat closed and strand the node as "offline". A
// multi-homed host's `hostname -I` can exceed 16 (IPv6 SLAAC/privacy addresses
// + Docker/libvirt/VLAN bridges); truncate to the documented cap and accept
// (the kept 16 are the primary addresses) rather than 400 every heartbeat
// forever and desync the node (audit R7-IPCAP).
ipAddresses: z
.preprocess(
(value) => (Array.isArray(value) ? value.slice(0, 16) : value),
z.array(z.string().trim().min(1).max(120)).max(16),
)
.default([]),
runtime: nodeRuntimeSchema.optional(),
status: nodeStatusSchema.default("online"),
})
Expand Down
12 changes: 12 additions & 0 deletions apps/api/src/agent-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,18 @@ export function registerAgentRoutes({

app.get("/api/v1/recording-jobs/:jobId", async (c, next) => {
const jobId = c.req.param("jobId");

// `/api/v1/recording-jobs/export` is a static operator route that collides
// with this `:jobId` param route under Hono's TrieRouter (the app falls back
// to TrieRouter because of the nodes static+param collision — see audit G1).
// This node-auth handler is registered first, so without this guard it would
// answer `/export` with a node-credential 401 instead of the operator export
// handler. A real job id is never the literal "export", so defer it downstream.
if (jobId === "export") {
await next();
return c.res;
}

const token = bearerToken(c.req.header("authorization"));

if (!token)
Expand Down
19 changes: 10 additions & 9 deletions apps/api/src/metrics.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type {
AuditEvent,
HealthEvent,
MeterFrame,
RecorderNode,
RecordingJob,
RecordingSummary,
UploadQueueItem,
import {
isNodeReachable,
type AuditEvent,
type HealthEvent,
type MeterFrame,
type RecorderNode,
type RecordingJob,
type RecordingSummary,
type UploadQueueItem,
} from "@rakkr/shared";

import { nodeOfflineEventType, scheduledLowSignalEventType } from "./watchdog-runner.js";
Expand Down Expand Up @@ -45,7 +46,7 @@ export function renderPrometheusMetrics(input: PrometheusMetricsInput) {
pushHelp(lines, "rakkr_node_online", "Whether a recorder node is reachable.");
pushType(lines, "rakkr_node_online", "gauge");
for (const node of input.nodes) {
pushMetric(lines, "rakkr_node_online", nodeLabels(node), node.status === "offline" ? 0 : 1);
pushMetric(lines, "rakkr_node_online", nodeLabels(node), isNodeReachable(node.status) ? 1 : 0);
}

pushHelp(lines, "rakkr_recording_active", "Active recording jobs by recorder node.");
Expand Down
12 changes: 8 additions & 4 deletions apps/api/src/node-action-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ interface NodeActionState {
}

const monitorChunkMaxAgeMs = 5000;
const unavailableNodeStatuses = new Set<RecorderNode["status"]>(["offline"]);
// A provisioning node has never sent a heartbeat, so listen/meters/start are as
// unavailable as an offline node — but the reason differs (it is not "offline",
// it has never been online), so callers get an accurate label (audit H1-3).
const unavailableNodeStatuses = new Set<RecorderNode["status"]>(["offline", "provisioning"]);

export function registerNodeActionRoutes({
app,
Expand Down Expand Up @@ -103,6 +106,7 @@ function nodeActions(
) {
const basePath = `/api/v1/nodes/${node.id}`;
const nodeAvailable = !unavailableNodeStatuses.has(node.status);
const unavailableReason = node.status === "provisioning" ? "node_provisioning" : "node_offline";

return {
detail: actionState({
Expand Down Expand Up @@ -132,15 +136,15 @@ function nodeActions(
permission: "listen:monitor",
permissions,
ready: nodeAvailable && readiness.listen,
reason: nodeAvailable ? "monitor_source_unavailable" : "node_offline",
reason: nodeAvailable ? "monitor_source_unavailable" : unavailableReason,
}),
meters: actionState({
href: `${basePath}/meters`,
method: "GET",
permission: "node:read",
permissions,
ready: nodeAvailable && readiness.meters,
reason: nodeAvailable ? "meter_frame_not_found" : "node_offline",
reason: nodeAvailable ? "meter_frame_not_found" : unavailableReason,
}),
rotateCredential: actionState({
href: `${basePath}/credentials/rotate`,
Expand All @@ -156,7 +160,7 @@ function nodeActions(
permission: "recording:create",
permissions,
ready: nodeAvailable,
reason: "node_offline",
reason: unavailableReason,
}),
};
}
Expand Down
19 changes: 15 additions & 4 deletions apps/api/src/node-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "@rakkr/shared";

import { registerAgentReleaseRoutes } from "./agent-release-routes.js";
import type { AgentReleaseService } from "./agent-release-service.js";
import type { AuthResult } from "./auth-service.js";
import { buildMeterFrame, demoMetersEnabled } from "./demo-data.js";
import type {
Expand All @@ -36,6 +37,7 @@ import type { NodeStore } from "./node-store.js";
import { NodeStoreError } from "./node-store.js";

interface NodeRouteDependencies {
agentReleaseService?: AgentReleaseService;
app: Hono<AppBindings>;
bootstrapStore: NodeBootstrapStore;
currentAuth: (c: Context<AppBindings>) => AuthResult;
Expand Down Expand Up @@ -151,6 +153,7 @@ const nodeInterfaceUpdateSchema = z
.refine(hasNodeUpdate, "At least one interface field is required");

export function registerNodeRoutes({
agentReleaseService: releaseService,
app,
bootstrapStore,
canServeWholeNodeMonitor = async () => true,
Expand All @@ -167,6 +170,18 @@ export function registerNodeRoutes({
scopedNodes,
sshCredentialStore,
}: NodeRouteDependencies) {
// Register the static `/api/v1/nodes/agent-release` route BEFORE any
// `/api/v1/nodes/:nodeId` route. The node route set mixes a static child
// (`/export`) with a param child (`:nodeId`) at the same trie position, which
// Hono's RegExpRouter cannot represent, so the whole app falls back to the
// registration-order-sensitive TrieRouter. A static route registered AFTER
// `:nodeId` loses the match and gets swallowed by the detail handler (→ 404).
// Keeping this first mirrors how `/export` avoids the collision.
registerAgentReleaseRoutes({
agentReleaseService: releaseService,
app,
requirePermission,
});
registerNodeInventoryRoutes({
app,
currentAuth,
Expand All @@ -193,10 +208,6 @@ export function registerNodeRoutes({
requirePermission,
scopedNodes,
});
registerAgentReleaseRoutes({
app,
requirePermission,
});
registerNodeSshCredentialRoutes({
app,
currentAuth,
Expand Down
14 changes: 12 additions & 2 deletions apps/api/src/node-store-updates.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AudioInterface, RecorderNode } from "@rakkr/shared";
import type { AudioInterface, NodeStatus, RecorderNode } from "@rakkr/shared";

import { nonEmptyAudioDefaults } from "./node-metadata.js";
import type {
Expand All @@ -11,6 +11,16 @@ import type {
// paths. Extracted from node-store.ts to keep it under the LOC budget; the type
// imports above are erased, so the store <-> updates edge is not a runtime cycle.

// A heartbeat proves the node is in contact right now, so it must never leave
// the node looking never-contacted (`provisioning`) or stale (`offline`) — the
// controller owns the lifecycle state machine, so a first heartbeat promotes a
// provisioning node to live, and a node (or a stale/rolled-back agent) cannot
// self-report itself back out of offline detection (audit N4). Other live
// statuses the agent may report (recording/degraded/alerting) pass through.
export function heartbeatStatus(status: NodeStatus): NodeStatus {
return status === "provisioning" || status === "offline" ? "online" : status;
}

export function updatedNodeHeartbeat(node: RecorderNode, input: NodeHeartbeatInput): RecorderNode {
return {
...node,
Expand All @@ -19,7 +29,7 @@ export function updatedNodeHeartbeat(node: RecorderNode, input: NodeHeartbeatInp
ipAddresses: input.ipAddresses,
lastSeenAt: new Date().toISOString(),
runtime: input.runtime ?? node.runtime,
status: input.status,
status: heartbeatStatus(input.status),
};
}

Expand Down
9 changes: 7 additions & 2 deletions apps/api/src/node-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ import {
recorderInterfaceToRow,
recorderNodeToRow,
} from "./node-store-mappers.js";
import { updatedNode, updatedNodeHeartbeat, updatedNodeInterface } from "./node-store-updates.js";
import {
heartbeatStatus,
updatedNode,
updatedNodeHeartbeat,
updatedNodeInterface,
} from "./node-store-updates.js";

export interface NodeEnrollmentInput {
agentVersion: string;
Expand Down Expand Up @@ -448,7 +453,7 @@ class PostgresNodeStore implements NodeStore {
lastSeenAt: new Date(),
metadata: nodeMetadata(row.metadata, nodeRuntimeFromInput(input.runtime, row.metadata)),
network: { ipAddresses: input.ipAddresses },
status: input.status,
status: heartbeatStatus(input.status),
updatedAt: new Date(),
})
.where(eq(nodeRows.id, nodeId));
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/schedule-route-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ export function buildSchedule(input: ScheduleInput): ScheduleSummary {
tags: uniqueTags(input.tags),
timezone: input.timezone,
titleTemplate: input.titleTemplate,
uploadPolicyIds: input.uploadPolicyIds,
// Dedup server-side (the client also dedups) so the server is authoritative:
// each id fans a recording out to its own upload queue item (audit R4-1).
uploadPolicyIds: [...new Set(input.uploadPolicyIds)],
watchdogPolicyId: input.watchdogPolicyId,
};
}
Expand Down Expand Up @@ -105,6 +107,10 @@ export function sanitizeScheduleUpdate(
updates.tags = uniqueTags(input.tags);
}

if (input.uploadPolicyIds) {
updates.uploadPolicyIds = [...new Set(input.uploadPolicyIds)];
}

return updates;
}

Expand Down
Loading