Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { helpWantedRoutes } from './routes/projects-help-wanted.js';
import { projectMembershipRoutes } from './routes/projects-members.js';
import { previewRoutes } from './routes/preview.js';
import { samlRoutes } from './routes/saml.js';
import { internalRoutes } from './routes/internal.js';

declare module 'fastify' {
interface FastifyInstance {
Expand Down Expand Up @@ -174,6 +175,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
await fastify.register(projectMembershipRoutes);
await fastify.register(previewRoutes);
await fastify.register(samlRoutes);
await fastify.register(internalRoutes);

// Serve the OpenAPI JSON at the spec-mandated path /api/_openapi.json
// (swagger-ui also exposes it at /api/_docs/json, but the spec names this path)
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export const EnvSchema = z.object({
CFP_DATA_REMOTE: z.string().optional(),
/** Branch the push daemon pushes to. Defaults to the repo's current HEAD. */
CFP_DATA_BRANCH: z.string().optional(),
/**
* Shared bearer-token secret for the `POST /api/_internal/reload-data`
* webhook (see specs/behaviors/storage.md#hot-reload). When unset, the
* route is still registered but responds 503 — hot-reload is opt-in per
* environment via the sealed Secret in the GitOps repo.
*/
CFP_DATA_RELOAD_SECRET: z.string().min(32).optional(),
/** Which private-storage backend to use. */
STORAGE_BACKEND: z.enum(['s3', 'filesystem']),
/** Filesystem backend: absolute path to the private-storage directory. */
Expand Down Expand Up @@ -73,6 +80,7 @@ export const envJsonSchema = {
CFP_DATA_REPO_PATH: { type: 'string' },
CFP_DATA_REMOTE: { type: 'string' },
CFP_DATA_BRANCH: { type: 'string' },
CFP_DATA_RELOAD_SECRET: { type: 'string', minLength: 32 },
STORAGE_BACKEND: { type: 'string', enum: ['s3', 'filesystem'] },
CFP_PRIVATE_STORAGE_PATH: { type: 'string' },
S3_ENDPOINT: { type: 'string' },
Expand Down
262 changes: 262 additions & 0 deletions apps/api/src/routes/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
/**
* Internal-only routes.
*
* Currently houses the hot-reload webhook documented in
* `specs/behaviors/storage.md#hot-reload`:
*
* POST /api/_internal/reload-data
* - Hidden from the public OpenAPI doc (`schema.hide: true`)
* - Auth: `Authorization: Bearer <CFP_DATA_RELOAD_SECRET>` —
* constant-time compare, length-checked first to avoid a different
* early-exit timing side channel
* - Body: optional `{ branch?: string, commitHash?: string }`
* - Behavior:
* 1. If `commitHash` is given AND already an ancestor of local
* HEAD, return 200 noChanges without touching the lock or
* the network (handles self-trigger from push-daemon pushes).
* 2. Otherwise call `fastify.reconcileDataRepo({ branch })`
* under the data-repo lock.
* 3. If outcome === 'in-sync', skip the rebuild and return
* 200 noChanges.
* 4. Otherwise rebuild the in-memory state + FTS index in place,
* invalidate the facet cache, and return 200 with
* `rebuilt: true`.
*
* The route is registered unconditionally — when `CFP_DATA_RELOAD_SECRET`
* is unset, requests get a 503 at request time. This keeps the
* deployment surface stable across environments that haven't been
* configured for hot reloads yet.
*/
import { execFile } from 'node:child_process';
import { timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';

import type { FastifyInstance } from 'fastify';

import { errorResponse, ok } from '../lib/response.js';
import { reloadInMemoryStateAndFts } from '../store/memory/reload.js';
import type { ReconcileOutcome } from '../store/reconcile.js';

const exec = promisify(execFile);

/** Bearer-token regex — case-insensitive, single whitespace separator. */
const BEARER_RE = /^Bearer\s+(\S+)$/i;

/**
* Constant-time comparison of two strings. Returns false (without
* decoding) when the lengths differ — comparing length is its own early
* exit, but a length mismatch tells the attacker only the secret's
* length, which we accept as a cheaper-than-real-world side channel.
*/
function safeEqualStrings(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const ab = Buffer.from(a, 'utf8');
const bb = Buffer.from(b, 'utf8');
// Buffer.from('utf8') for ASCII tokens has length === string length,
// so the lengths still match; defensive guard anyway.
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}

interface ReloadBody {
readonly branch?: string;
readonly commitHash?: string;
}

const reloadBodySchema = {
type: 'object',
additionalProperties: false,
properties: {
branch: { type: 'string', minLength: 1, maxLength: 200 },
commitHash: {
type: 'string',
// git supports abbreviated SHAs; allow 4-40 hex chars
pattern: '^[0-9a-fA-F]{4,40}$',
},
},
} as const;

export async function internalRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post<{ Body: ReloadBody | undefined }>(
'/api/_internal/reload-data',
{
schema: {
hide: true,
body: reloadBodySchema,
},
},
async (request, reply) => {
const traceId = (request as typeof request & { traceId?: string }).traceId;
const expected = fastify.config.CFP_DATA_RELOAD_SECRET;

// ---- Bearer auth (route refuses to do anything before this passes) ----
const headerValue = request.headers['authorization'];
const headerStr = Array.isArray(headerValue) ? headerValue[0] : headerValue;
const match = typeof headerStr === 'string' ? BEARER_RE.exec(headerStr) : null;
const provided = match?.[1];

if (!provided) {
return reply
.code(401)
.send(errorResponse('unauthorized', 'Authentication required', traceId));
}

// 503 takes precedence over a token-match check ONLY when the
// operator hasn't even configured the secret. We still return 401
// on a missing/empty header BEFORE checking the secret so that
// unauthenticated probes don't get a different status code
// depending on whether the env var is set. Order matters: header
// present → check secret configured → check token equality.
if (!expected) {
return reply.code(503).send(
errorResponse(
'service_unavailable',
'hot-reload not configured',
traceId,
),
);
}

if (!safeEqualStrings(provided, expected)) {
return reply
.code(401)
.send(errorResponse('unauthorized', 'Authentication required', traceId));
}

// ---- Resolve effective branch ----
const body: ReloadBody = request.body ?? {};
const branch = body.branch ?? fastify.config.CFP_DATA_BRANCH;
if (!branch) {
return reply
.code(400)
.send(
errorResponse(
'bad_request',
'branch is required when CFP_DATA_BRANCH is unset',
traceId,
),
);
}

const startedAt = Date.now();
const repoPath = fastify.config.CFP_DATA_REPO_PATH;
const commitHash = body.commitHash;

// ---- Cheap pre-check: is `commitHash` already in local HEAD? ----
// No lock acquired here — `merge-base --is-ancestor` only reads
// git's object store, which is safe alongside an in-flight
// gitsheets transact. Worst case (a transact lands between this
// check and the answer being read) we accept a stale "no" and
// proceed to the full reconcile.
if (commitHash) {
try {
const head = (
await exec('git', ['rev-parse', 'HEAD'], { cwd: repoPath })
).stdout.trim();
await exec(
'git',
['merge-base', '--is-ancestor', commitHash, head],
{ cwd: repoPath },
);
// `git merge-base --is-ancestor` exits 0 = is-ancestor, 1 = not.
// Reaching here means exit 0; short-circuit.
fastify.log.info(
{ branch, commitHash, head },
'hot-reload short-circuit: commit already in local HEAD',
);
return reply.send(
ok({
noChanges: true,
outcome: 'in-sync' as ReconcileOutcome,
head,
durationMs: Date.now() - startedAt,
}),
);
} catch (err) {
// exec throws with `code: 1` when not-ancestor (continue to
// reconcile) and with other codes when the commit is unknown
// or git itself fails. We treat all non-zero as "fall through
// to reconcile" — the reconcile will fetch and try again.
fastify.log.debug(
{
err: err instanceof Error ? err.message : String(err),
branch,
commitHash,
},
'hot-reload pre-check fell through to full reconcile',
);
}
}

// ---- Reconcile under the data-repo lock ----
const result = await fastify.reconcileDataRepo({ branch });

if (result.outcome === 'in-sync') {
fastify.log.info(
{ branch, commit: result.newCommit, outcome: result.outcome },
'hot-reload: nothing to do (in-sync after fetch)',
);
return reply.send(
ok({
noChanges: true,
outcome: result.outcome,
oldCommit: result.oldCommit,
newCommit: result.newCommit,
durationMs: Date.now() - startedAt,
}),
);
}

// ---- Rebuild ----
try {
await reloadInMemoryStateAndFts(fastify);
} catch (err) {
// The in-memory state + FTS index may be partially mutated.
// Log loudly so the operator knows a pod restart is warranted,
// then 500 the request.
fastify.log.error(
{
err: err instanceof Error ? err.message : String(err),
branch,
outcome: result.outcome,
oldCommit: result.oldCommit,
newCommit: result.newCommit,
},
'hot-reload: in-memory rebuild failed AFTER reconcile — pod is in an undefined state, restart required',
);
return reply.code(500).send(
errorResponse(
'internal_error',
'Hot-reload rebuild failed — pod restart required',
traceId,
),
);
}

const durationMs = Date.now() - startedAt;
fastify.log.info(
{
branch,
outcome: result.outcome,
oldCommit: result.oldCommit,
newCommit: result.newCommit,
conflictBranch: result.conflictBranch,
durationMs,
},
'hot-reload: in-memory state + FTS rebuilt',
);

return reply.send(
ok({
noChanges: false,
rebuilt: true,
outcome: result.outcome,
oldCommit: result.oldCommit,
newCommit: result.newCommit,
...(result.conflictBranch ? { conflictBranch: result.conflictBranch } : {}),
durationMs,
}),
);
},
);
}
Loading