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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ coverage/
# cli-backend agent sandboxes — runtime scratch only (keep the dir + its README)
/workspaces/*
!/workspaces/README.md

# working-code workspace — cloned target repos agents inspect/modify (ENG-013); never committed
/workspace/
59 changes: 35 additions & 24 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { listBudgets, listPendingApprovals, resolveApproval } from "@eng/db"
import { Hono } from "hono"
import { type AppEnv, authMiddleware } from "./auth"
import { persistenceFromEnv } from "./persistence"
import { artifactPath, SHAPING_STAGES } from "./shaping"
import {
Expand All @@ -16,35 +18,22 @@ import {
*/
const persistence = persistenceFromEnv()

export const app = new Hono()
export const app = new Hono<AppEnv>()

app.onError((err, c) => c.json({ error: String(err) }, 500))

app.get("/health", (c) => c.json({ ok: true }))

// Auth gate (ENG-004): GET read views stay open; mutating /api requests require a Bearer token when
// API_AUTH_TOKEN is set. The resolved principal is recorded on approval events below.
app.use("/api/*", authMiddleware())

app.get("/api/tickets", async (c) => c.json({ tickets: await persistence.tracker.list() }))
app.get("/api/audit", async (c) => c.json({ events: await persistence.audit.query() }))
// Pending human gates, derived from the audit log. A roadmap sign-off is pending when the latest
// roadmap-lifecycle event for an epic is `roadmap_requested` (not yet approved/decomposed).
app.get("/api/approvals", async (c) => {
const events = await persistence.audit.query()
const seen = new Set<string>()
const pending: { epicId: string; kind: string; at: string }[] = []
for (const e of events) {
const epicId = typeof e.payload?.epicId === "string" ? (e.payload.epicId as string) : null
if (!epicId || seen.has(epicId)) continue
if (
e.kind === "roadmap_requested" ||
e.kind === "roadmap_approved" ||
e.kind === "epic_decomposed" ||
e.kind === "decomposition_skipped"
) {
seen.add(epicId)
if (e.kind === "roadmap_requested") pending.push({ epicId, kind: "roadmap", at: e.at })
}
}
return c.json({ approvals: pending })
})
// Pending human gates as first-class records (ENG-006): roadmap · pr_merge · deploy.
app.get("/api/approvals", async (c) => c.json({ approvals: await listPendingApprovals() }))
// Per-role budget/cost view for the dashboard (ENG-010).
app.get("/api/budgets", async (c) => c.json({ budgets: await listBudgets() }))

// Goal hierarchy authoring (through the persistence port, so the backend is swappable). Lets the
// human/PM decompose work under multiple goals + epics; tickets then target a chosen epic.
Expand Down Expand Up @@ -105,7 +94,16 @@ app.post("/api/epics/:id/decompose", async (c) => {
app.post("/api/epics/:id/approve-roadmap", async (c) => {
const id = c.req.param("id")
const signaled = await approveRoadmap(id)
return c.json({ epicId: id, gate: "roadmap", signaled })
const by = c.get("actor")
// Resolve the first-class approval record (ENG-006) + audit who released the gate (ENG-004).
await resolveApproval({ kind: "roadmap", epicId: id, decidedBy: by })
await persistence.audit.append({
actor: "human",
kind: "approval_decided",
ticketId: null,
payload: { epicId: id, gate: "roadmap", by, signaled },
})
return c.json({ epicId: id, gate: "roadmap", signaled, by })
})

// Create a ticket through the tracker under a chosen epic (or seed the default Mission→Goal→Epic
Expand Down Expand Up @@ -144,5 +142,18 @@ app.post("/api/tickets/:id/approve", async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { gate?: "merge" | "deploy" }
const gate = body.gate === "deploy" ? "deploy" : "merge"
await approveTicket(id, gate)
return c.json({ ticketId: id, gate, signaled: true })
const by = c.get("actor")
// Resolve the first-class approval record (ENG-006) + audit who released the gate (ENG-004).
await resolveApproval({
kind: gate === "deploy" ? "deploy" : "pr_merge",
ticketId: id,
decidedBy: by,
})
await persistence.audit.append({
actor: "human",
kind: "approval_decided",
ticketId: id,
payload: { gate, by },
})
return c.json({ ticketId: id, gate, signaled: true, by })
})
70 changes: 70 additions & 0 deletions apps/server/src/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Hono } from "hono"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { type AppEnv, authMiddleware, checkAuth } from "./auth"

describe("checkAuth", () => {
it("allows dev mode (principal 'dev') when no token is configured", () => {
expect(checkAuth({}, undefined)).toEqual({ ok: true, principal: "dev" })
})

it("accepts a correct Bearer token and defaults the principal to 'operator'", () => {
const r = checkAuth({ authorization: "Bearer s3cret" }, "s3cret")
expect(r).toEqual({ ok: true, principal: "operator" })
})

it("honors the X-Actor header as the principal", () => {
const r = checkAuth({ authorization: "Bearer s3cret", actor: "toby" }, "s3cret")
expect(r).toEqual({ ok: true, principal: "toby" })
})

it("rejects a missing or wrong token", () => {
expect(checkAuth({}, "s3cret").ok).toBe(false)
expect(checkAuth({ authorization: "Bearer nope" }, "s3cret").ok).toBe(false)
expect(checkAuth({ authorization: "s3cret" }, "s3cret").ok).toBe(false) // missing "Bearer "
})
})

describe("authMiddleware", () => {
const prev = process.env.API_AUTH_TOKEN
let app: Hono<AppEnv>

beforeEach(() => {
app = new Hono<AppEnv>()
app.use("/api/*", authMiddleware())
app.get("/api/thing", (c) => c.json({ actor: c.get("actor") }))
app.post("/api/thing", (c) => c.json({ actor: c.get("actor") }))
})
afterEach(() => {
if (prev === undefined) delete process.env.API_AUTH_TOKEN
else process.env.API_AUTH_TOKEN = prev
})

it("leaves GET read views open", async () => {
process.env.API_AUTH_TOKEN = "s3cret"
const res = await app.request("/api/thing")
expect(res.status).toBe(200)
})

it("401s an unauthenticated mutation when a token is configured", async () => {
process.env.API_AUTH_TOKEN = "s3cret"
const res = await app.request("/api/thing", { method: "POST" })
expect(res.status).toBe(401)
})

it("allows an authenticated mutation and exposes the principal", async () => {
process.env.API_AUTH_TOKEN = "s3cret"
const res = await app.request("/api/thing", {
method: "POST",
headers: { authorization: "Bearer s3cret", "x-actor": "toby" },
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ actor: "toby" })
})

it("allows mutations in dev mode when no token is configured", async () => {
delete process.env.API_AUTH_TOKEN
const res = await app.request("/api/thing", { method: "POST" })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ actor: "dev" })
})
})
78 changes: 78 additions & 0 deletions apps/server/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { timingSafeEqual } from "node:crypto"
import type { MiddlewareHandler } from "hono"

/**
* API authentication (ENG-004). The approval gates are the product's core value (invariant #4), so
* mutating requests must be authenticated and the deciding principal recorded. Auth activates when
* `API_AUTH_TOKEN` is set; when it isn't, the server runs in an open "dev mode" (and warns loudly at
* startup) so the local `docker compose up` quickstart keeps working — set the token to enforce.
*/

/** Hono environment: the authenticated principal attributed to writes (audit identity). */
export type AppEnv = { Variables: { actor: string } }

const ACTOR_HEADER = "x-actor"

export interface AuthResult {
ok: boolean
/** Who to attribute the write to in the audit log. */
principal: string
}

function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a)
const bb = Buffer.from(b)
if (ab.length !== bb.length) return false
return timingSafeEqual(ab, bb)
}

/**
* Decide whether a request is authorized and who the principal is. Pure + header-driven for testing.
* - No configured token → dev mode: allowed, principal `dev`.
* - Configured token → requires `Authorization: Bearer <token>`; principal from `X-Actor`
* (default `operator`). A bad/missing token → not ok.
*/
export function checkAuth(
headers: { authorization?: string | null; actor?: string | null },
configuredToken: string | undefined,
): AuthResult {
if (!configuredToken) return { ok: true, principal: "dev" }
const match = /^Bearer\s+(.+)$/i.exec((headers.authorization ?? "").trim())
const token = match?.[1]
if (!token || !safeEqual(token, configuredToken)) {
return { ok: false, principal: "anonymous" }
}
const actor = (headers.actor ?? "").trim()
return { ok: true, principal: actor || "operator" }
}

/**
* Hono middleware enforcing auth on mutating requests. GET (read views) and `/health` stay open; any
* non-GET request requires auth when a token is configured. The resolved principal is stored on the
* context for handlers to attribute approvals/writes to.
*/
export function authMiddleware(): MiddlewareHandler<AppEnv> {
return async (c, next) => {
if (c.req.method === "GET") {
c.set("actor", "anonymous")
return next()
}
const result = checkAuth(
{ authorization: c.req.header("authorization"), actor: c.req.header(ACTOR_HEADER) },
process.env.API_AUTH_TOKEN,
)
if (!result.ok) return c.json({ error: "unauthorized" }, 401)
c.set("actor", result.principal)
return next()
}
}

/** Log a one-time warning if auth is disabled (no token configured). Call at server startup. */
export function warnIfAuthDisabled(): void {
if (!process.env.API_AUTH_TOKEN) {
console.warn(
"[auth] API_AUTH_TOKEN is not set — mutating endpoints (incl. approval gates) are UNAUTHENTICATED. " +
"Set API_AUTH_TOKEN to require a Bearer token before any shared/live deployment.",
)
}
}
2 changes: 2 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { serve } from "@hono/node-server"
import { app } from "./app"
import { warnIfAuthDisabled } from "./auth"
import { startHeartbeat } from "./scheduler"

const port = Number(process.env.PORT ?? 3000)

warnIfAuthDisabled()
serve({ fetch: app.fetch, port })
console.log(`[server] listening on http://localhost:${port}`)

Expand Down
Loading