From cc637e37327c61c7c24e53ae3bda283b5fce9f0d Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 12:33:19 -0400 Subject: [PATCH 01/31] feat(api): serve portal-shaped health and error envelope Move liveness to GET /api/health { stage, sha } with a Node 24 image on 8080 so ALB probes and deploy verify do not need auth or a database ping. --- .dockerignore | 21 ++++++++ Dockerfile | 27 +++++++++++ README.md | 4 +- packages/api/docs/index.md | 7 +-- packages/api/docs/local-dev.md | 6 +-- packages/api/openapi/components/schemas.yaml | 37 ++++++++++++-- packages/api/openapi/openapi.yaml | 14 ++++-- packages/api/openapi/paths/health.yaml | 25 ++++------ packages/api/openapi/paths/me.yaml | 15 ++++-- packages/api/openapi/paths/ready.yaml | 39 +++++++++++++++ packages/api/src/app.test.ts | 51 +++++++++++++++++--- packages/api/src/app.ts | 8 +-- packages/api/src/auth/middleware.ts | 22 ++++----- packages/api/src/build-info.ts | 9 ++++ packages/api/src/env.test.ts | 7 ++- packages/api/src/env.ts | 17 ++++++- packages/api/src/http.ts | 46 ++++++++++++++++++ packages/api/src/index.ts | 4 +- packages/api/src/routes/health.ts | 16 ++++-- packages/api/src/routes/me.ts | 3 +- redocly.yaml | 16 +++--- 21 files changed, 318 insertions(+), 76 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 packages/api/openapi/paths/ready.yaml create mode 100644 packages/api/src/build-info.ts create mode 100644 packages/api/src/http.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6bb9975 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git +.github +.cursor +dist +packages/*/dist +build +coverage +e2e +node_modules +src +public +placeholder +terraform +docs +*.md +.env +.env.* +playwright-report +test-results +.idea +.vscode diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6909726 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 AS build +WORKDIR /app +COPY package.json package-lock.json ./ +COPY packages/shared/package.json packages/shared/package.json +COPY packages/api/package.json packages/api/package.json +RUN npm ci +COPY packages/shared packages/shared +COPY packages/api packages/api +RUN npm run build:shared && npm run build -w @seahaven-ap/api +ARG GIT_SHA=unknown +RUN node -e "require('node:fs').writeFileSync('packages/api/dist/build-info.js', 'export const BUILD_GIT_SHA = ' + JSON.stringify(process.argv[1]) + ';\\n')" "$GIT_SHA" + +FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 +RUN addgroup -S app && adduser -S -G app app +WORKDIR /app +COPY package.json package-lock.json ./ +COPY packages/shared/package.json packages/shared/package.json +COPY packages/api/package.json packages/api/package.json +RUN npm ci --omit=dev +COPY --from=build /app/packages/shared/dist packages/shared/dist +COPY --from=build /app/packages/api/dist packages/api/dist +COPY --from=build /app/packages/api/drizzle packages/api/drizzle +USER app +ENV NODE_ENV=production \ + API_PORT=8080 +EXPOSE 8080 +CMD ["node", "packages/api/dist/index.js"] diff --git a/README.md b/README.md index 3ad12bc..c5df9a4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sea Haven AP -Internal accounts payable automation for Sea Haven Industries (`ap.seahaven.com`). +Internal accounts payable automation for Sea Haven Industries. Local API is `http://127.0.0.1:8787`. CloudFront on seahaven-dev is the first hosted origin. ## Workspace layout @@ -37,7 +37,7 @@ API listens on http://127.0.0.1:8787. Vite proxies `/api` to that port. Smoke: ```bash -curl -s http://127.0.0.1:8787/health +curl -s http://127.0.0.1:8787/api/health curl -s http://127.0.0.1:8787/api/me ``` diff --git a/packages/api/docs/index.md b/packages/api/docs/index.md index ca1808a..a403a84 100644 --- a/packages/api/docs/index.md +++ b/packages/api/docs/index.md @@ -1,6 +1,7 @@ # Sea Haven AP API -HTTP API for Sea Haven accounts payable (`ap.seahaven.com`). +HTTP API for Sea Haven accounts payable. Local server is `http://127.0.0.1:8787`. -This foundation documents the health and session smoke surface introduced in -AP-14. Domain CRUD lands in later tickets and extends this OpenAPI tree. +Liveness is `GET /api/health` (`{ stage, sha }`) with no auth and no database +ping. Readiness is optional `GET /api/ready`. Errors use +`{ error: { code, message, correlationId } }`. diff --git a/packages/api/docs/local-dev.md b/packages/api/docs/local-dev.md index 7b02dcd..92b7ef9 100644 --- a/packages/api/docs/local-dev.md +++ b/packages/api/docs/local-dev.md @@ -18,7 +18,7 @@ Defaults: ## Smoke ```bash -curl -s http://127.0.0.1:8787/health +curl -s http://127.0.0.1:8787/api/health curl -s http://127.0.0.1:8787/api/me ``` @@ -34,5 +34,5 @@ npm run docs:preview ``` `docs:preview` runs `redocly build-docs` (CLI v2) and opens the HTML at -`/tmp/seahaven-ap-api-docs.html`. Published OpenAPI servers point at -`https://ap.seahaven.com`. Use this page for the local docs view. +`/tmp/seahaven-ap-api-docs.html`. OpenAPI servers point at +`http://127.0.0.1:8787`. Use this page for the local docs view. diff --git a/packages/api/openapi/components/schemas.yaml b/packages/api/openapi/components/schemas.yaml index 8ab8819..994feb7 100644 --- a/packages/api/openapi/components/schemas.yaml +++ b/packages/api/openapi/components/schemas.yaml @@ -4,10 +4,39 @@ Error: - error properties: error: - type: string - description: Human-readable error message. - example: Missing or invalid Authorization header. + type: object + required: + - code + - message + - correlationId + properties: + code: + type: string + description: Machine-readable error code. + example: NOT_FOUND + message: + type: string + description: Human-readable error message. + example: Not found. + correlationId: + type: string + description: Request correlation identifier echoed from x-correlation-id when present. + example: 11111111-1111-4111-8111-111111111111 HealthResponse: + type: object + required: + - stage + - sha + properties: + stage: + type: string + description: Deployment stage name. + example: local + sha: + type: string + description: Git SHA inlined at image build. + example: deadbeef +ReadyResponse: type: object required: - status @@ -15,7 +44,7 @@ HealthResponse: properties: status: type: string - description: Process health marker. + description: Process readiness marker. example: ok database: type: string diff --git a/packages/api/openapi/openapi.yaml b/packages/api/openapi/openapi.yaml index e070c33..ad65cc4 100644 --- a/packages/api/openapi/openapi.yaml +++ b/packages/api/openapi/openapi.yaml @@ -2,20 +2,22 @@ openapi: 3.1.0 info: title: Sea Haven AP API version: 1.0.0 - description: "Accounts payable HTTP API for Sea Haven Industries. Foundation surface for health and authenticated session smoke under local and AWS runtimes." + description: "Accounts payable HTTP API for Sea Haven Industries. Liveness, readiness, and authenticated session smoke under local and AWS runtimes." license: name: Proprietary servers: - - url: https://ap.seahaven.com - description: Production API host for ap.seahaven.com. + - url: http://127.0.0.1:8787 + description: Local API process used by Vite proxy and unit tests. tags: - name: Health - description: Liveness and dependency checks for the API process. + description: Liveness and readiness checks for the API process. - name: Session description: Authenticated caller identity after JWT or local dev auth. paths: - /health: + /api/health: $ref: ./paths/health.yaml + /api/ready: + $ref: ./paths/ready.yaml /api/me: $ref: ./paths/me.yaml components: @@ -27,5 +29,7 @@ components: $ref: ./components/schemas.yaml#/Error HealthResponse: $ref: ./components/schemas.yaml#/HealthResponse + ReadyResponse: + $ref: ./components/schemas.yaml#/ReadyResponse MeResponse: $ref: ./components/schemas.yaml#/MeResponse diff --git a/packages/api/openapi/paths/health.yaml b/packages/api/openapi/paths/health.yaml index a53bd7c..818e55f 100644 --- a/packages/api/openapi/paths/health.yaml +++ b/packages/api/openapi/paths/health.yaml @@ -1,20 +1,20 @@ get: tags: - Health - summary: Check API and database liveness - description: Returns ok when the process can ping the configured database. - operationId: get-health + summary: Check API process liveness + description: Returns stage and build SHA. ALB target-group probes call this without auth or a database ping. + operationId: get-api-health security: [] responses: "200": - description: API process is healthy and the database answered. + description: API process is up. content: application/json: schema: $ref: ../components/schemas.yaml#/HealthResponse example: - status: ok - database: up + stage: local + sha: deadbeef "400": description: Bad request. content: @@ -22,12 +22,7 @@ get: schema: $ref: ../components/schemas.yaml#/Error example: - error: Bad request. - "503": - description: Database ping failed. - content: - application/json: - schema: - $ref: ../components/schemas.yaml#/Error - example: - error: Database is unavailable. + error: + code: VALIDATION_ERROR + message: Bad request. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/me.yaml b/packages/api/openapi/paths/me.yaml index f98682f..2e2be50 100644 --- a/packages/api/openapi/paths/me.yaml +++ b/packages/api/openapi/paths/me.yaml @@ -25,7 +25,10 @@ get: schema: $ref: ../components/schemas.yaml#/Error example: - error: Missing or invalid Authorization header. + error: + code: UNAUTHENTICATED + message: Missing or invalid Authorization header. + correlationId: 11111111-1111-4111-8111-111111111111 "409": description: Email is already linked to a different Cognito subject. content: @@ -33,7 +36,10 @@ get: schema: $ref: ../components/schemas.yaml#/Error example: - error: Email admin@seahavenind.com is already linked to a different identity. + error: + code: CONFLICT + message: Email admin@seahavenind.com is already linked to a different identity. + correlationId: 11111111-1111-4111-8111-111111111111 "404": description: Not found. content: @@ -41,4 +47,7 @@ get: schema: $ref: ../components/schemas.yaml#/Error example: - error: Not found. + error: + code: NOT_FOUND + message: Not found. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/ready.yaml b/packages/api/openapi/paths/ready.yaml new file mode 100644 index 0000000..7ba6d77 --- /dev/null +++ b/packages/api/openapi/paths/ready.yaml @@ -0,0 +1,39 @@ +get: + tags: + - Health + summary: Check API database readiness + description: Pings the configured database. Not the ALB target. Optional for operators and deploy verify. + operationId: get-api-ready + security: [] + responses: + "200": + description: Database answered. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/ReadyResponse + example: + status: ok + database: up + "400": + description: Bad request. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: VALIDATION_ERROR + message: Bad request. + correlationId: 11111111-1111-4111-8111-111111111111 + "503": + description: Database ping failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: DATABASE_UNAVAILABLE + message: Database is unavailable. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/src/app.test.ts b/packages/api/src/app.test.ts index 27e1de1..d221587 100644 --- a/packages/api/src/app.test.ts +++ b/packages/api/src/app.test.ts @@ -3,6 +3,7 @@ import { createApp } from "./app.js"; import type { Db } from "./db/client.js"; import { loadEnv } from "./env.js"; import type { AuthUser } from "./auth/upsert-user.js"; +import type { ErrorEnvelope } from "./http.js"; const sampleUser: AuthUser = { id: "11111111-1111-4111-8111-111111111111", @@ -56,6 +57,14 @@ function createTestDb(options?: { pingFails?: boolean }): Db { }; } +function expectEnvelope(body: unknown, code: string, message: string) { + const envelope = body as ErrorEnvelope; + expect(envelope.error.code).toBe(code); + expect(envelope.error.message).toBe(message); + expect(envelope.error.correlationId).toEqual(expect.any(String)); + expect(envelope.error.correlationId.length).toBeGreaterThan(0); +} + describe("createApp smoke routes", () => { const env = loadEnv({ NODE_ENV: "test", @@ -66,18 +75,25 @@ describe("createApp smoke routes", () => { DEV_AUTH_ROLE: sampleUser.role, }); - it("GET /health returns ok when the database pings", async () => { + it("GET /api/health returns stage and sha without auth or a database ping", async () => { + const app = createApp(env, createTestDb({ pingFails: true })); + const response = await app.request("/api/health"); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ stage: "local", sha: "unknown" }); + }); + + it("GET /api/ready returns ok when the database pings", async () => { const app = createApp(env, createTestDb()); - const response = await app.request("/health"); + const response = await app.request("/api/ready"); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ status: "ok", database: "up" }); }); - it("GET /health returns error payload when the database is down", async () => { + it("GET /api/ready returns the error envelope when the database is down", async () => { const app = createApp(env, createTestDb({ pingFails: true })); - const response = await app.request("/health"); + const response = await app.request("/api/ready"); expect(response.status).toBe(503); - await expect(response.json()).resolves.toEqual({ error: "Database is unavailable." }); + expectEnvelope(await response.json(), "DATABASE_UNAVAILABLE", "Database is unavailable."); }); it("GET /api/me returns the upserted caller under DEV_AUTH_BYPASS", async () => { @@ -102,8 +118,29 @@ describe("createApp smoke routes", () => { const app = createApp(secureEnv, createTestDb()); const response = await app.request("/api/me"); expect(response.status).toBe(401); - await expect(response.json()).resolves.toEqual({ - error: "Missing or invalid Authorization header.", + expectEnvelope( + await response.json(), + "UNAUTHENTICATED", + "Missing or invalid Authorization header.", + ); + }); + + it("unknown paths return the 404 error envelope", async () => { + const app = createApp(env, createTestDb()); + const response = await app.request("/does-not-exist"); + expect(response.status).toBe(404); + expectEnvelope(await response.json(), "NOT_FOUND", "Not found."); + }); + + it("unhandled throws return the 500 error envelope", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const app = createApp(env, createTestDb()); + app.get("/explode", () => { + throw new Error("boom"); }); + const response = await app.request("/explode"); + expect(response.status).toBe(500); + expectEnvelope(await response.json(), "INTERNAL_ERROR", "Internal server error."); + errorSpy.mockRestore(); }); }); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 340d45c..aaafdcb 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -4,22 +4,22 @@ import type { Db } from "./db/client.js"; import { createAuthMiddleware, type AppBindings } from "./auth/middleware.js"; import { createHealthRoutes } from "./routes/health.js"; import { createMeRoutes } from "./routes/me.js"; +import { errorJson } from "./http.js"; export function createApp(env: ApiEnv, handle: Db) { const app = new Hono(); const auth = createAuthMiddleware(env, handle); - app.route("/", createHealthRoutes(handle)); - const api = new Hono(); + api.route("/", createHealthRoutes(env, handle)); api.use("*", auth); api.route("/", createMeRoutes()); app.route("/api", api); - app.notFound((c) => c.json({ error: "Not found." }, 404)); + app.notFound((c) => errorJson(c, 404, "NOT_FOUND", "Not found.")); app.onError((error, c) => { console.error(error); - return c.json({ error: "Internal server error." }, 500); + return errorJson(c, 500, "INTERNAL_ERROR", "Internal server error."); }); return app; diff --git a/packages/api/src/auth/middleware.ts b/packages/api/src/auth/middleware.ts index a832bcf..36e10fa 100644 --- a/packages/api/src/auth/middleware.ts +++ b/packages/api/src/auth/middleware.ts @@ -5,6 +5,7 @@ import { IdentityConflictError, upsertUserFromIdentity } from "./upsert-user.js" import type { ApiEnv, UserRole } from "../env.js"; import { isUserRole } from "../env.js"; import type { Db } from "../db/client.js"; +import { errorJson } from "../http.js"; export type AppVariables = { user: AuthUser; @@ -73,7 +74,7 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) { c.set("user", user); } catch (error) { if (error instanceof IdentityConflictError) { - return c.json({ error: error.message }, 409); + return errorJson(c, 409, "CONFLICT", error.message); } throw error; } @@ -83,11 +84,11 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) { const header = c.req.header("authorization"); if (!header?.startsWith("Bearer ")) { - return c.json({ error: "Missing or invalid Authorization header." }, 401); + return errorJson(c, 401, "UNAUTHENTICATED", "Missing or invalid Authorization header."); } if (!jwks) { - return c.json({ error: "JWT verification is not configured." }, 401); + return errorJson(c, 401, "UNAUTHENTICATED", "JWT verification is not configured."); } const token = header.slice("Bearer ".length); @@ -97,21 +98,20 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) { issuer: env.cognitoIssuer, })); } catch { - return c.json({ error: "Invalid or expired token." }, 401); + return errorJson(c, 401, "UNAUTHENTICATED", "Invalid or expired token."); } if (!audienceMatches(payload, env.cognitoAudience)) { - return c.json({ error: "Token audience does not match this API." }, 401); + return errorJson(c, 401, "UNAUTHENTICATED", "Token audience does not match this API."); } const identity = identityFromPayload(payload); if (!identity) { - return c.json( - { - error: - "Token is missing required identity claims. Use a Cognito ID token or an access token that includes email.", - }, + return errorJson( + c, 401, + "UNAUTHENTICATED", + "Token is missing required identity claims. Use a Cognito ID token or an access token that includes email.", ); } @@ -125,7 +125,7 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) { }); } catch (error) { if (error instanceof IdentityConflictError) { - return c.json({ error: error.message }, 409); + return errorJson(c, 409, "CONFLICT", error.message); } throw error; } diff --git a/packages/api/src/build-info.ts b/packages/api/src/build-info.ts new file mode 100644 index 0000000..408b803 --- /dev/null +++ b/packages/api/src/build-info.ts @@ -0,0 +1,9 @@ +// Build-time constant. The API image Dockerfile overwrites the compiled +// `build-info.js` with an inlined GIT_SHA after `tsc`. Keep the exact +// expression `process.env.GIT_SHA` here so local `tsx` still reads the env. +// +// Consumers must prefer BUILD_GIT_SHA over a runtime GIT_SHA env var. A +// deployed task can carry a stale env var from an earlier infra apply while +// its image has since been updated by deploy-api.yaml. + +export const BUILD_GIT_SHA: string = process.env.GIT_SHA?.trim() || ""; diff --git a/packages/api/src/env.test.ts b/packages/api/src/env.test.ts index 26d642c..8313272 100644 --- a/packages/api/src/env.test.ts +++ b/packages/api/src/env.test.ts @@ -11,14 +11,17 @@ describe("loadEnv", () => { expect(env.devAuthBypass).toBe(true); expect(env.devAuthRole).toBe("viewer"); expect(env.port).toBe(8787); + expect(env.stage).toBe("local"); + expect(env.sha).toBe("unknown"); }); - it("allows DEV_AUTH_BYPASS in test", () => { + it("uses STAGE when provided", () => { const env = loadEnv({ NODE_ENV: "test", DEV_AUTH_BYPASS: "true", + STAGE: "dev", }); - expect(env.devAuthBypass).toBe(true); + expect(env.stage).toBe("dev"); }); it("rejects DEV_AUTH_BYPASS in production", () => { diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 05d0ee2..c3f4dd6 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -1,3 +1,5 @@ +import { BUILD_GIT_SHA } from "./build-info.js"; + export const USER_ROLES = ["admin", "ap_processor", "approver", "viewer"] as const; export type UserRole = (typeof USER_ROLES)[number]; @@ -6,8 +8,12 @@ export function isUserRole(value: string): value is UserRole { return (USER_ROLES as readonly string[]).includes(value); } +const LOCAL_NODE_ENVS = new Set(["development", "test"]); + export type ApiEnv = { nodeEnv: string; + stage: string; + sha: string; port: number; databaseDriver: "postgres" | "data-api"; databaseUrl: string; @@ -39,8 +45,7 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { } const devAuthBypass = env.DEV_AUTH_BYPASS === "true"; - const localNodeEnvs = new Set(["development", "test"]); - if (devAuthBypass && !localNodeEnvs.has(nodeEnv)) { + if (devAuthBypass && !LOCAL_NODE_ENVS.has(nodeEnv)) { throw new Error("DEV_AUTH_BYPASS is only allowed when NODE_ENV is development or test."); } @@ -49,8 +54,16 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { throw new Error(`Invalid DEV_AUTH_ROLE: ${rawRole}`); } + const local = LOCAL_NODE_ENVS.has(nodeEnv); + const stage = env.STAGE?.trim() || (local ? "local" : "dev"); + // SHA is inlined at image build. Runtime GIT_SHA is a local-dev convenience + // only and must not be the deployed source of truth. + const sha = BUILD_GIT_SHA || env.GIT_SHA?.trim() || "unknown"; + const base: ApiEnv = { nodeEnv, + stage, + sha, port: Number(env.API_PORT ?? "8787"), databaseDriver, databaseUrl: env.DATABASE_URL ?? "postgresql://seahaven:seahaven@127.0.0.1:5432/seahaven_ap", diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts new file mode 100644 index 0000000..3afbd8c --- /dev/null +++ b/packages/api/src/http.ts @@ -0,0 +1,46 @@ +import { randomUUID } from "node:crypto"; +import type { Context } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +export type ErrorCode = + | "UNAUTHENTICATED" + | "FORBIDDEN" + | "NOT_FOUND" + | "VALIDATION_ERROR" + | "CONFLICT" + | "INTERNAL_ERROR" + | "DATABASE_UNAVAILABLE"; + +export const CORRELATION_HEADER = "x-correlation-id"; + +export type ErrorEnvelope = { + error: { + code: ErrorCode; + message: string; + correlationId: string; + }; +}; + +export function correlationIdFrom(c: Context): string { + const raw = c.req.header(CORRELATION_HEADER)?.trim(); + if (raw && raw.length <= 128) { + return raw; + } + return randomUUID(); +} + +export function errorBody(code: ErrorCode, message: string, correlationId: string): ErrorEnvelope { + return { error: { code, message, correlationId } }; +} + +export function errorJson( + c: Context, + status: ContentfulStatusCode, + code: ErrorCode, + message: string, + correlationId?: string, +) { + const id = correlationId ?? correlationIdFrom(c); + c.header(CORRELATION_HEADER, id); + return c.json(errorBody(code, message, id), status); +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 9f54b4f..74f214d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -8,8 +8,8 @@ async function main(): Promise { const handle = createDb(env); const app = createApp(env, handle); - const server = serve({ fetch: app.fetch, port: env.port }, (info) => { - console.log(`@seahaven-ap/api listening on http://127.0.0.1:${info.port}`); + const server = serve({ fetch: app.fetch, hostname: "0.0.0.0", port: env.port }, (info) => { + console.log(`@seahaven-ap/api listening on http://0.0.0.0:${info.port}`); }); const shutdown = async () => { diff --git a/packages/api/src/routes/health.ts b/packages/api/src/routes/health.ts index 0aec002..3b75e67 100644 --- a/packages/api/src/routes/health.ts +++ b/packages/api/src/routes/health.ts @@ -1,16 +1,26 @@ import { Hono } from "hono"; +import type { ApiEnv } from "../env.js"; import type { Db } from "../db/client.js"; import { pingDb } from "../db/client.js"; +import { CORRELATION_HEADER, correlationIdFrom, errorJson } from "../http.js"; -export function createHealthRoutes(handle: Db) { +export function createHealthRoutes(env: ApiEnv, handle: Db) { const routes = new Hono(); - routes.get("/health", async (c) => { + routes.get("/health", (c) => { + const correlationId = correlationIdFrom(c); + c.header(CORRELATION_HEADER, correlationId); + return c.json({ stage: env.stage, sha: env.sha }); + }); + + routes.get("/ready", async (c) => { try { await pingDb(handle); + const correlationId = correlationIdFrom(c); + c.header(CORRELATION_HEADER, correlationId); return c.json({ status: "ok", database: "up" }); } catch { - return c.json({ error: "Database is unavailable." }, 503); + return errorJson(c, 503, "DATABASE_UNAVAILABLE", "Database is unavailable."); } }); diff --git a/packages/api/src/routes/me.ts b/packages/api/src/routes/me.ts index e96b02f..1e98f08 100644 --- a/packages/api/src/routes/me.ts +++ b/packages/api/src/routes/me.ts @@ -1,6 +1,7 @@ import { Hono } from "hono"; import type { AppBindings } from "../auth/middleware.js"; import { can } from "../auth/rbac.js"; +import { errorJson } from "../http.js"; export function createMeRoutes() { const routes = new Hono(); @@ -8,7 +9,7 @@ export function createMeRoutes() { routes.get("/me", (c) => { const user = c.get("user"); if (!can(user.role, "read:me")) { - return c.json({ error: "Forbidden." }, 403); + return errorJson(c, 403, "FORBIDDEN", "Forbidden."); } return c.json({ diff --git a/redocly.yaml b/redocly.yaml index 4c640d9..fdee367 100644 --- a/redocly.yaml +++ b/redocly.yaml @@ -21,9 +21,9 @@ rules: assertions: defined: true operation-4xx-response: error - # Off: the live contract is {"error": string} as plain application/json - # (serialization.py error_response). Adopting RFC 7807 would be a runtime - # + SHOC-contract change, decided against 2026-07-24. + # Off: the live contract is {"error": { code, message, correlationId }} as + # plain application/json, matching the portal BFF envelope. RFC 7807 is not + # the AP contract. operation-4xx-problem-details-rfc7807: off operation-operationId: error rule/operationId-casing: @@ -64,6 +64,7 @@ rules: - docs - openapi.json - health + - ready - me - api paths-kebab-case: error @@ -96,12 +97,9 @@ rules: - application/json - text/html no-server-example.com: error - rule/no-server-localhost: - subject: - type: Server - property: url - assertions: - notPattern: /(localhost|127.0.0.1) + # This batch documents the local API only (http://127.0.0.1:8787). Production + # hostname documentation is AP-12. + rule/no-server-localhost: off operation-singular-tag: error operation-tag-defined: error rule/tag-description: From bbfa6e4a3c05e7a7e4b64ede4c855a2e7f9c5f2c Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 12:39:00 -0400 Subject: [PATCH 02/31] feat(api): switch live auth to host cookie BFF Replace Bearer as the documented session path with Cognito hosted UI plus __Host-ap_* cookies so the SPA can call /api with credentials include. --- .env.example | 3 + README.md | 2 +- packages/api/docs/auth.md | 33 ++- packages/api/openapi/components/security.yaml | 10 +- packages/api/openapi/openapi.yaml | 18 +- packages/api/openapi/paths/auth-callback.yaml | 43 +++ packages/api/openapi/paths/auth-login.yaml | 41 +++ packages/api/openapi/paths/auth-logout.yaml | 32 +++ packages/api/openapi/paths/auth-refresh.yaml | 32 +++ packages/api/openapi/paths/me.yaml | 6 +- packages/api/src/app.test.ts | 143 +++++++++- packages/api/src/app.ts | 34 ++- packages/api/src/auth/cognito.ts | 104 ++++++++ packages/api/src/auth/cookies.test.ts | 138 ++++++++++ packages/api/src/auth/cookies.ts | 248 ++++++++++++++++++ packages/api/src/auth/middleware.ts | 44 +++- packages/api/src/auth/oauth.test.ts | 20 ++ packages/api/src/auth/oauth.ts | 229 ++++++++++++++++ packages/api/src/auth/origin-verify.ts | 18 ++ packages/api/src/auth/pkce.ts | 23 ++ packages/api/src/env.ts | 6 + packages/api/src/routes/auth.ts | 16 ++ redocly.yaml | 5 + 23 files changed, 1207 insertions(+), 41 deletions(-) create mode 100644 packages/api/openapi/paths/auth-callback.yaml create mode 100644 packages/api/openapi/paths/auth-login.yaml create mode 100644 packages/api/openapi/paths/auth-logout.yaml create mode 100644 packages/api/openapi/paths/auth-refresh.yaml create mode 100644 packages/api/src/auth/cognito.ts create mode 100644 packages/api/src/auth/cookies.test.ts create mode 100644 packages/api/src/auth/cookies.ts create mode 100644 packages/api/src/auth/oauth.test.ts create mode 100644 packages/api/src/auth/oauth.ts create mode 100644 packages/api/src/auth/origin-verify.ts create mode 100644 packages/api/src/auth/pkce.ts create mode 100644 packages/api/src/routes/auth.ts diff --git a/.env.example b/.env.example index 6d07ee7..9099a4d 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,9 @@ DEV_AUTH_ROLE=admin # Cognito (required when DEV_AUTH_BYPASS=false) # COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/ # COGNITO_AUDIENCE= +# COGNITO_DOMAIN= +# APP_ORIGIN=http://127.0.0.1:3000 +# ORIGIN_VERIFY_SECRET= # Aurora Data API driver (DATABASE_DRIVER=data-api) # AWS_REGION=us-east-1 diff --git a/README.md b/README.md index c5df9a4..480e36c 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ curl -s http://127.0.0.1:8787/api/health curl -s http://127.0.0.1:8787/api/me ``` -`DEV_AUTH_BYPASS=true` is local-only and only allowed when `NODE_ENV` is `development` or `test` (rejected for production, staging, preview, and any other value). +`DEV_AUTH_BYPASS=true` is local-only and only allowed when `NODE_ENV` is `development` or `test` (rejected for production, staging, preview, and any other value). Cookie session names are `ap_*` locally and `__Host-ap_*` outside local. API roles (source of truth): `admin`, `ap_processor`, `approver`, `viewer`. Frontend mocks still use `ap_operator` until AP-15 remaps them. diff --git a/packages/api/docs/auth.md b/packages/api/docs/auth.md index 9d3e01c..a52b634 100644 --- a/packages/api/docs/auth.md +++ b/packages/api/docs/auth.md @@ -1,9 +1,32 @@ # Authentication -## Cognito bearer tokens +## Cookie session (live path) -Production and seahaven-dev expect a Bearer Cognito token verified against the -user pool JWKS and issuer. +The API is a same-origin BFF. Cognito hosted UI issues tokens. The API stores +them in host-only cookies: + +- `__Host-ap_at` access token (HttpOnly) +- `__Host-ap_it` ID token (HttpOnly) +- `__Host-ap_rt` refresh token (HttpOnly, path `/` when host-prefixed) +- `__Host-ap_sess` session hint (not HttpOnly; display name and email) +- `__Host-ap_oauth` PKCE state during login + +Local `NODE_ENV` `development` or `test` drops the `__Host-` prefix and the +Secure flag so `http://127.0.0.1:8787` works (`ap_at`, `ap_it`, `ap_rt`). + +Routes: + +- `GET /api/auth/login` +- `GET /api/auth/callback` +- `POST /api/auth/refresh` +- `POST /api/auth/logout` +- `GET /api/me` reads the ID cookie, verifies it, and upserts `users` by `sub` + +CloudFront sends `X-Origin-Verify` on `/api/*`. `GET /api/health` skips that +check so the ALB probe succeeds. Mutating `/api/*` requests also require a +matching `Origin`. + +## Cognito tokens Audience check: @@ -11,9 +34,7 @@ Audience check: - Access tokens (`token_use=access`): `client_id` must equal `COGNITO_AUDIENCE`. Identity claims (`sub`, `email`, and `name` or `cognito:username`) are required. -Prefer a Cognito **ID token**, which carries email/name by default. An access -token is accepted only when it includes an `email` claim (for example via a -pre-token-generation enrichment). +`GET /api/me` uses the ID cookie. Optional role claim mapping: diff --git a/packages/api/openapi/components/security.yaml b/packages/api/openapi/components/security.yaml index 6b16271..ed66f35 100644 --- a/packages/api/openapi/components/security.yaml +++ b/packages/api/openapi/components/security.yaml @@ -1,5 +1,5 @@ -bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: Cognito ID token preferred. Access tokens require an email claim. Local DEV_AUTH_BYPASS skips verification. +cookieAuth: + type: apiKey + in: cookie + name: __Host-ap_it + description: HttpOnly session id-token cookie set by GET /api/auth/callback. Local stage uses ap_it without the __Host- prefix. diff --git a/packages/api/openapi/openapi.yaml b/packages/api/openapi/openapi.yaml index ad65cc4..c3fab19 100644 --- a/packages/api/openapi/openapi.yaml +++ b/packages/api/openapi/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.1.0 info: title: Sea Haven AP API version: 1.0.0 - description: "Accounts payable HTTP API for Sea Haven Industries. Liveness, readiness, and authenticated session smoke under local and AWS runtimes." + description: "Accounts payable HTTP API for Sea Haven Industries. Liveness, cookie session, and authenticated session smoke under local and AWS runtimes." license: name: Proprietary servers: @@ -12,18 +12,26 @@ tags: - name: Health description: Liveness and readiness checks for the API process. - name: Session - description: Authenticated caller identity after JWT or local dev auth. + description: Cookie session via Cognito hosted UI, plus caller identity after upsert. paths: /api/health: $ref: ./paths/health.yaml /api/ready: $ref: ./paths/ready.yaml + /api/auth/login: + $ref: ./paths/auth-login.yaml + /api/auth/callback: + $ref: ./paths/auth-callback.yaml + /api/auth/refresh: + $ref: ./paths/auth-refresh.yaml + /api/auth/logout: + $ref: ./paths/auth-logout.yaml /api/me: $ref: ./paths/me.yaml components: securitySchemes: - bearerAuth: - $ref: ./components/security.yaml#/bearerAuth + cookieAuth: + $ref: ./components/security.yaml#/cookieAuth schemas: Error: $ref: ./components/schemas.yaml#/Error @@ -33,3 +41,5 @@ components: $ref: ./components/schemas.yaml#/ReadyResponse MeResponse: $ref: ./components/schemas.yaml#/MeResponse +security: + - cookieAuth: [] diff --git a/packages/api/openapi/paths/auth-callback.yaml b/packages/api/openapi/paths/auth-callback.yaml new file mode 100644 index 0000000..b86106f --- /dev/null +++ b/packages/api/openapi/paths/auth-callback.yaml @@ -0,0 +1,43 @@ +get: + tags: + - Session + summary: Complete hosted UI sign-in + description: Exchanges the authorization code, sets HttpOnly session cookies, and redirects to returnTo. + operationId: get-api-auth-callback + security: [] + parameters: + - name: code + in: query + required: false + description: Authorization code from Cognito. + schema: + type: string + example: abcdef + - name: state + in: query + required: false + description: PKCE state echoed from login. + schema: + type: string + example: state-token + - name: error + in: query + required: false + description: Cognito error code when sign-in failed. + schema: + type: string + example: access_denied + responses: + "302": + description: Redirect to the SPA or the login error page. + "400": + description: Bad request. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: VALIDATION_ERROR + message: Bad request. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/auth-login.yaml b/packages/api/openapi/paths/auth-login.yaml new file mode 100644 index 0000000..c782154 --- /dev/null +++ b/packages/api/openapi/paths/auth-login.yaml @@ -0,0 +1,41 @@ +get: + tags: + - Session + summary: Start hosted UI sign-in + description: Redirects the browser to Cognito hosted UI with PKCE S256. Sets the oauth cookie. + operationId: get-api-auth-login + security: [] + parameters: + - name: returnTo + in: query + required: false + description: Relative path to return to after sign-in. + schema: + type: string + default: / + example: /invoices + responses: + "302": + description: Redirect to Cognito hosted UI. + "400": + description: Bad request. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: VALIDATION_ERROR + message: Bad request. + correlationId: 11111111-1111-4111-8111-111111111111 + "500": + description: Cognito is not configured. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: INTERNAL_ERROR + message: Cognito is not configured. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/auth-logout.yaml b/packages/api/openapi/paths/auth-logout.yaml new file mode 100644 index 0000000..ce5162b --- /dev/null +++ b/packages/api/openapi/paths/auth-logout.yaml @@ -0,0 +1,32 @@ +post: + tags: + - Session + summary: End the API session + description: Clears session cookies. Idempotent when already signed out. + operationId: post-api-auth-logout + security: [] + responses: + "204": + description: Session cleared. + "403": + description: CSRF origin check failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: FORBIDDEN + message: Origin is not allowed. + correlationId: 11111111-1111-4111-8111-111111111111 + "400": + description: Bad request. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: VALIDATION_ERROR + message: Bad request. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/auth-refresh.yaml b/packages/api/openapi/paths/auth-refresh.yaml new file mode 100644 index 0000000..4b8af3a --- /dev/null +++ b/packages/api/openapi/paths/auth-refresh.yaml @@ -0,0 +1,32 @@ +post: + tags: + - Session + summary: Refresh the session cookies + description: Rotates HttpOnly token cookies when the refresh token is still valid. + operationId: post-api-auth-refresh + security: [] + responses: + "204": + description: Session refreshed. + "401": + description: Missing or invalid refresh token. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: UNAUTHENTICATED + message: Missing refresh token. + correlationId: 11111111-1111-4111-8111-111111111111 + "403": + description: CSRF origin check failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: + code: FORBIDDEN + message: Origin is not allowed. + correlationId: 11111111-1111-4111-8111-111111111111 diff --git a/packages/api/openapi/paths/me.yaml b/packages/api/openapi/paths/me.yaml index 2e2be50..c8fe5d5 100644 --- a/packages/api/openapi/paths/me.yaml +++ b/packages/api/openapi/paths/me.yaml @@ -5,7 +5,7 @@ get: description: Upserts the caller into users on first request and returns the stored profile used by the SPA session smoke path. operationId: get-api-me security: - - bearerAuth: [] + - cookieAuth: [] responses: "200": description: Authenticated user profile. @@ -19,7 +19,7 @@ get: name: Dev Admin role: admin "401": - description: Missing or invalid bearer token. + description: Missing or invalid session cookie. content: application/json: schema: @@ -27,7 +27,7 @@ get: example: error: code: UNAUTHENTICATED - message: Missing or invalid Authorization header. + message: Missing id token. correlationId: 11111111-1111-4111-8111-111111111111 "409": description: Email is already linked to a different Cognito subject. diff --git a/packages/api/src/app.test.ts b/packages/api/src/app.test.ts index d221587..8e43314 100644 --- a/packages/api/src/app.test.ts +++ b/packages/api/src/app.test.ts @@ -108,7 +108,7 @@ describe("createApp smoke routes", () => { }); }); - it("GET /api/me rejects missing bearer token when bypass is off", async () => { + it("GET /api/me rejects missing session cookies when bypass is off", async () => { const secureEnv = loadEnv({ NODE_ENV: "test", DEV_AUTH_BYPASS: "false", @@ -118,11 +118,7 @@ describe("createApp smoke routes", () => { const app = createApp(secureEnv, createTestDb()); const response = await app.request("/api/me"); expect(response.status).toBe(401); - expectEnvelope( - await response.json(), - "UNAUTHENTICATED", - "Missing or invalid Authorization header.", - ); + expectEnvelope(await response.json(), "UNAUTHENTICATED", "Missing id token."); }); it("unknown paths return the 404 error envelope", async () => { @@ -144,3 +140,138 @@ describe("createApp smoke routes", () => { errorSpy.mockRestore(); }); }); + +describe("cookie BFF", () => { + function setCookies(response: Response): string[] { + const getSetCookie = response.headers.getSetCookie?.bind(response.headers); + if (getSetCookie) return getSetCookie(); + const single = response.headers.get("set-cookie"); + return single ? [single] : []; + } + + it("GET /api/auth/login sets __Host-ap_oauth in a production-like NODE_ENV", async () => { + const env = loadEnv({ + NODE_ENV: "production", + STAGE: "dev", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + COGNITO_DOMAIN: "auth.dev.example", + APP_ORIGIN: "https://d111111abcdef8.cloudfront.net", + }); + const app = createApp(env, createTestDb()); + const response = await app.request("/api/auth/login"); + expect(response.status).toBe(302); + const cookies = setCookies(response); + const oauth = cookies.find((item) => item.startsWith("__Host-ap_oauth=")); + expect(oauth).toBeDefined(); + expect(oauth).toContain("HttpOnly"); + expect(oauth).toMatch(/(?:^|; )Secure(?:;|$)/); + expect(oauth).not.toMatch(/Domain=/i); + expect(response.headers.get("location")).toContain("https://auth.dev.example/oauth2/authorize"); + }); + + it("GET /api/auth/login omits __Host- and Secure on the local bypass path", async () => { + const env = loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + COGNITO_DOMAIN: "auth.dev.example", + }); + const app = createApp(env, createTestDb()); + const response = await app.request("/api/auth/login"); + expect(response.status).toBe(302); + const cookies = setCookies(response); + const oauth = cookies.find((item) => item.startsWith("ap_oauth=")); + expect(oauth).toBeDefined(); + expect(oauth).toContain("HttpOnly"); + expect(oauth).not.toMatch(/(?:^|; )Secure(?:;|$)/); + expect(cookies.some((item) => item.startsWith("__Host-ap_oauth="))).toBe(false); + }); + + it("GET /api/auth/callback sets __Host-ap_* token cookies", async () => { + const env = loadEnv({ + NODE_ENV: "production", + STAGE: "dev", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + COGNITO_DOMAIN: "auth.dev.example", + APP_ORIGIN: "https://d111111abcdef8.cloudfront.net", + }); + const login = await createApp(env, createTestDb()).request( + "/api/auth/login?returnTo=/invoices", + ); + const oauthCookie = setCookies(login).find((item) => item.startsWith("__Host-ap_oauth=")); + expect(oauthCookie).toBeDefined(); + const location = new URL(login.headers.get("location") ?? ""); + const state = location.searchParams.get("state") ?? ""; + const app = createApp(env, createTestDb(), { + tokens: { + exchangeCode: async () => ({ + accessToken: "at", + idToken: "it", + refreshToken: "rt", + }), + refresh: async () => ({ accessToken: "at", idToken: "it", refreshToken: "rt" }), + revoke: async () => undefined, + }, + }); + const response = await app.request(`/api/auth/callback?code=abc&state=${state}`, { + headers: { cookie: oauthCookie!.split(";")[0] }, + }); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/invoices"); + const cookies = setCookies(response); + expect( + cookies.some((item) => item.startsWith("__Host-ap_at=") && item.includes("Secure")), + ).toBe(true); + expect(cookies.some((item) => item.startsWith("__Host-ap_it="))).toBe(true); + expect(cookies.some((item) => item.startsWith("__Host-ap_rt="))).toBe(true); + }); + + it("GET /api/me succeeds with an id cookie and fails without", async () => { + const env = loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "false", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + }); + const app = createApp(env, createTestDb(), { + verifyToken: async () => ({ + sub: sampleUser.cognitoSub, + email: sampleUser.email, + name: sampleUser.name, + aud: "test-audience", + token_use: "id", + }), + }); + const missing = await app.request("/api/me"); + expect(missing.status).toBe(401); + const ok = await app.request("/api/me", { headers: { cookie: "ap_it=fake-id-token" } }); + expect(ok.status).toBe(200); + await expect(ok.json()).resolves.toEqual({ + id: sampleUser.id, + email: sampleUser.email, + name: sampleUser.name, + role: sampleUser.role, + }); + }); + + it("returns 403 when origin-verify is missing on non-health /api", async () => { + const env = loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + ORIGIN_VERIFY_SECRET: "origin-secret", + }); + const app = createApp(env, createTestDb()); + const health = await app.request("/api/health"); + expect(health.status).toBe(200); + const me = await app.request("/api/me"); + expect(me.status).toBe(403); + expectEnvelope(await me.json(), "FORBIDDEN", "Origin is not allowed."); + const allowed = await app.request("/api/me", { + headers: { "x-origin-verify": "origin-secret" }, + }); + expect(allowed.status).toBe(200); + }); +}); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index aaafdcb..83f4b8f 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -1,18 +1,44 @@ import { Hono } from "hono"; import type { ApiEnv } from "./env.js"; import type { Db } from "./db/client.js"; -import { createAuthMiddleware, type AppBindings } from "./auth/middleware.js"; +import { createAuthMiddleware, type AppBindings, type AuthDeps } from "./auth/middleware.js"; import { createHealthRoutes } from "./routes/health.js"; import { createMeRoutes } from "./routes/me.js"; +import { createAuthRoutes } from "./routes/auth.js"; import { errorJson } from "./http.js"; +import { cloudFrontOriginAllowed } from "./auth/origin-verify.js"; +import { csrfAllowed, isMutating } from "./auth/oauth.js"; +import type { CognitoTokenClient } from "./auth/cognito.js"; -export function createApp(env: ApiEnv, handle: Db) { +export type AppDeps = AuthDeps & { + tokens?: CognitoTokenClient; +}; + +export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) { const app = new Hono(); - const auth = createAuthMiddleware(env, handle); + const auth = createAuthMiddleware(env, handle, deps); const api = new Hono(); - api.route("/", createHealthRoutes(env, handle)); + api.use("*", async (c, next) => { + const path = new URL(c.req.url).pathname; + if (path === "/api/health") { + await next(); + return; + } + if (!cloudFrontOriginAllowed(c, env.originVerifySecret || undefined)) { + return errorJson(c, 403, "FORBIDDEN", "Origin is not allowed."); + } + await next(); + }); + api.use("*", async (c, next) => { + if (isMutating(c.req.method) && !csrfAllowed(c, env)) { + return errorJson(c, 403, "FORBIDDEN", "Origin is not allowed."); + } + await next(); + }); api.use("*", auth); + api.route("/", createHealthRoutes(env, handle)); + api.route("/", createAuthRoutes(env, deps)); api.route("/", createMeRoutes()); app.route("/api", api); diff --git a/packages/api/src/auth/cognito.ts b/packages/api/src/auth/cognito.ts new file mode 100644 index 0000000..361bece --- /dev/null +++ b/packages/api/src/auth/cognito.ts @@ -0,0 +1,104 @@ +export type TokenSet = { + accessToken: string; + idToken: string; + refreshToken?: string; +}; + +export type CognitoTokenClient = { + exchangeCode(input: { code: string; verifier: string; redirectUri: string }): Promise; + refresh(refreshToken: string): Promise; + revoke(refreshToken: string): Promise; +}; + +export function hostedOrigin(domain: string): string { + const trimmed = domain.trim().replace(/\/$/, ""); + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return `https://${trimmed}`; +} + +export function authorizeUrl(input: { + domain: string; + clientId: string; + redirectUri: string; + state: string; + challenge: string; +}): string { + const url = new URL(`${hostedOrigin(input.domain)}/oauth2/authorize`); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", input.clientId); + url.searchParams.set("redirect_uri", input.redirectUri); + url.searchParams.set("scope", "openid email profile"); + url.searchParams.set("state", input.state); + url.searchParams.set("code_challenge", input.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("identity_provider", "Google"); + return url.toString(); +} + +export function callbackRedirectUri(appOrigin: string): string { + return `${appOrigin.replace(/\/$/, "")}/api/auth/callback`; +} + +export function createCognitoTokenClient( + config: { cognitoAudience: string; cognitoDomain: string }, + fetchFn: typeof fetch = fetch, +): CognitoTokenClient { + return { + exchangeCode(input) { + return tokenRequest(config, fetchFn, { + grant_type: "authorization_code", + code: input.code, + code_verifier: input.verifier, + redirect_uri: input.redirectUri, + }); + }, + async refresh(refreshToken) { + return tokenRequest(config, fetchFn, { + grant_type: "refresh_token", + refresh_token: refreshToken, + }); + }, + async revoke(refreshToken) { + const domain = config.cognitoDomain; + const clientId = config.cognitoAudience; + if (!domain || !clientId) throw new Error("Cognito domain and client id are required"); + const response = await fetchFn(`${hostedOrigin(domain)}/oauth2/revoke`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: refreshToken, client_id: clientId }).toString(), + }); + if (!response.ok && response.status !== 200) { + throw new Error(`revoke failed (${response.status})`); + } + }, + }; +} + +async function tokenRequest( + config: { cognitoAudience: string; cognitoDomain: string }, + fetchFn: typeof fetch, + fields: Record, +): Promise { + const domain = config.cognitoDomain; + const clientId = config.cognitoAudience; + if (!domain || !clientId) throw new Error("Cognito domain and client id are required"); + const response = await fetchFn(`${hostedOrigin(domain)}/oauth2/token`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: clientId, ...fields }).toString(), + }); + if (!response.ok) throw new Error(`token request failed (${response.status})`); + const body = (await response.json()) as { + access_token?: unknown; + id_token?: unknown; + refresh_token?: unknown; + }; + if (typeof body.access_token !== "string" || typeof body.id_token !== "string") { + throw new Error("token response missing tokens"); + } + return { + accessToken: body.access_token, + idToken: body.id_token, + refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : undefined, + }; +} diff --git a/packages/api/src/auth/cookies.test.ts b/packages/api/src/auth/cookies.test.ts new file mode 100644 index 0000000..c237ef9 --- /dev/null +++ b/packages/api/src/auth/cookies.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { + ACCESS_MAX_AGE_SEC, + COOKIE_ACCESS, + COOKIE_ID, + COOKIE_OAUTH, + COOKIE_REFRESH, + COOKIE_SESSION_HINT, + REFRESH_MAX_AGE_SEC, + clearCookie, + clearedSessionCookies, + cookieNames, + parseCookies, + serializeCookie, + serializeOauthCookie, + sessionCookieValue, + tokenCookies, +} from "./cookies.js"; + +const host = cookieNames("dev"); +const local = cookieNames("local"); + +describe("auth cookies", () => { + it("prefixes deployed cookies with __Host- and keeps local names unprefixed", () => { + expect(host).toEqual({ + access: `__Host-${COOKIE_ACCESS}`, + id: `__Host-${COOKIE_ID}`, + refresh: `__Host-${COOKIE_REFRESH}`, + oauth: `__Host-${COOKIE_OAUTH}`, + hint: `__Host-${COOKIE_SESSION_HINT}`, + }); + expect(local).toEqual({ + access: COOKIE_ACCESS, + id: COOKIE_ID, + refresh: COOKIE_REFRESH, + oauth: COOKIE_OAUTH, + hint: COOKIE_SESSION_HINT, + }); + }); + + it("sets HttpOnly, SameSite=Lax, Path=/, no Domain, and Secure outside local", () => { + const cookie = serializeCookie(host.access, "tok", { + maxAge: ACCESS_MAX_AGE_SEC, + stage: "dev", + }); + expect(cookie.startsWith(`${host.access}=`)).toBe(true); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Lax"); + expect(cookie).toContain("Path=/"); + expect(cookie).toMatch(/(?:^|; )Secure(?:;|$)/); + expect(cookie).not.toMatch(/Domain=/i); + expect(cookie).toContain(`Max-Age=${ACCESS_MAX_AGE_SEC}`); + }); + + it("omits Secure on local and scopes refresh to /api/auth", () => { + const cookie = serializeCookie(local.access, "tok", { + maxAge: ACCESS_MAX_AGE_SEC, + stage: "local", + }); + expect(cookie).toContain("HttpOnly"); + expect(cookie).not.toMatch(/(?:^|; )Secure(?:;|$)/); + expect(cookie).not.toMatch(/Domain=/i); + + const refresh = tokenCookies( + { accessToken: "a", idToken: "i", refreshToken: "r" }, + "local", + ).find((item) => item.startsWith(`${local.refresh}=`)); + expect(refresh).toContain("Path=/api/auth"); + expect(refresh).not.toMatch(/(?:^|; )Secure(?:;|$)/); + }); + + it("sets a non-HttpOnly session hint for 8h", () => { + const cookies = tokenCookies({ accessToken: "a", idToken: "i", refreshToken: "r" }, "dev"); + const hint = cookies.find((item) => item.startsWith(`${host.hint}=`)); + expect(hint).toContain(`${host.hint}=1`); + expect(hint).toContain(`Max-Age=${REFRESH_MAX_AGE_SEC}`); + expect(hint).not.toContain("HttpOnly"); + expect(hint).toContain("SameSite=Lax"); + expect(hint).toMatch(/(?:^|; )Secure(?:;|$)/); + }); + + it("ignores unprefixed session cookies on deployed stages", () => { + expect( + sessionCookieValue({ headers: { cookie: `${COOKIE_ACCESS}=legacy` } }, "access", "dev"), + ).toBeUndefined(); + expect( + sessionCookieValue( + { headers: { cookie: `${host.access}=host; ${COOKIE_ACCESS}=legacy` } }, + "access", + "dev", + ), + ).toBe("host"); + }); + + it("reads unprefixed session cookies only on local", () => { + expect( + sessionCookieValue({ headers: { cookie: `${COOKIE_ACCESS}=local` } }, "access", "local"), + ).toBe("local"); + }); + + it("parses Cookie headers and keeps the first duplicate", () => { + expect( + parseCookies({ + headers: { cookie: `${host.access}=first; ${host.access}=second` }, + }), + ).toEqual({ [host.access]: "first" }); + }); + + it("clears cookies with Max-Age=0 and the same host-only attributes", () => { + const cookie = clearCookie(host.access, "dev"); + expect(cookie).toContain("Max-Age=0"); + expect(cookie).toContain("HttpOnly"); + expect(cookie).not.toMatch(/Domain=/i); + expect(cookie).toMatch(/(?:^|; )Secure(?:;|$)/); + }); + + it("clears both __Host- and legacy ap_* cookies on deployed stages", () => { + const cookies = clearedSessionCookies("dev"); + expect( + cookies.some((item) => item.startsWith(`${host.refresh}=`) && item.includes("Max-Age=0")), + ).toBe(true); + expect( + cookies.some( + (item) => + item.startsWith(`${COOKIE_REFRESH}=`) && + item.includes("Max-Age=0") && + item.includes("Path=/"), + ), + ).toBe(true); + }); + + it("encodes oauth state without a Domain attribute", () => { + const cookie = serializeOauthCookie({ state: "st", verifier: "ver", returnTo: "/" }, "dev"); + expect(cookie.startsWith(`${host.oauth}=`)).toBe(true); + expect(cookie).toContain("HttpOnly"); + expect(cookie).not.toMatch(/Domain=/i); + }); +}); diff --git a/packages/api/src/auth/cookies.ts b/packages/api/src/auth/cookies.ts new file mode 100644 index 0000000..e04d460 --- /dev/null +++ b/packages/api/src/auth/cookies.ts @@ -0,0 +1,248 @@ +export const COOKIE_ACCESS = "ap_at"; +export const COOKIE_ID = "ap_it"; +export const COOKIE_REFRESH = "ap_rt"; +export const COOKIE_OAUTH = "ap_oauth"; +export const COOKIE_SESSION_HINT = "ap_sess"; + +export const ACCESS_MAX_AGE_SEC = 60 * 60; +export const REFRESH_MAX_AGE_SEC = 8 * 60 * 60; +export const OAUTH_MAX_AGE_SEC = 10 * 60; + +export type CookieEvent = { + cookies?: string[]; + headers?: Record; +}; + +export type OauthCookie = { + state: string; + verifier: string; + returnTo: string; +}; + +export type CookieNames = { + access: string; + id: string; + refresh: string; + oauth: string; + hint: string; +}; + +export type SessionHint = { + displayName: string; + email: string; +}; + +export function cookieNames(stage: string): CookieNames { + const prefix = stage === "local" ? "" : "__Host-"; + return { + access: `${prefix}${COOKIE_ACCESS}`, + id: `${prefix}${COOKIE_ID}`, + refresh: `${prefix}${COOKIE_REFRESH}`, + oauth: `${prefix}${COOKIE_OAUTH}`, + hint: `${prefix}${COOKIE_SESSION_HINT}`, + }; +} + +export function parseCookies(event: CookieEvent): Record { + const parsed: Record = {}; + for (const part of cookieParts(event)) { + const eq = part.indexOf("="); + if (eq <= 0) continue; + const name = part.slice(0, eq).trim(); + const value = part.slice(eq + 1).trim(); + if (!name) continue; + if (Object.prototype.hasOwnProperty.call(parsed, name)) continue; + parsed[name] = decodeCookieValue(value); + } + return parsed; +} + +export function cookieValue(event: CookieEvent, name: string): string | undefined { + const value = parseCookies(event)[name]; + return value ? value : undefined; +} + +export function sessionCookieValue( + event: CookieEvent, + kind: keyof CookieNames, + stage: string, +): string | undefined { + return cookieValue(event, cookieNames(stage)[kind]); +} + +export function serializeCookie( + name: string, + value: string, + options: { maxAge: number; stage: string; path?: string; httpOnly?: boolean }, +): string { + const hostPrefixed = name.startsWith("__Host-"); + const path = hostPrefixed ? "/" : (options.path ?? "/"); + const parts = [ + `${name}=${encodeURIComponent(value)}`, + `Path=${path}`, + "SameSite=Lax", + `Max-Age=${options.maxAge}`, + ]; + if (options.httpOnly !== false) parts.splice(2, 0, "HttpOnly"); + if (hostPrefixed || options.stage !== "local") parts.push("Secure"); + return parts.join("; "); +} + +export function clearCookie( + name: string, + stage: string, + options: { httpOnly?: boolean } = {}, +): string { + return serializeCookie(name, "", { + maxAge: 0, + stage, + path: cookiePath(name), + httpOnly: options.httpOnly, + }); +} + +export function tokenCookies( + tokens: { accessToken: string; idToken: string; refreshToken?: string }, + stage: string, +): string[] { + const names = cookieNames(stage); + const cookies = [ + serializeCookie(names.access, tokens.accessToken, { maxAge: ACCESS_MAX_AGE_SEC, stage }), + serializeCookie(names.id, tokens.idToken, { maxAge: ACCESS_MAX_AGE_SEC, stage }), + ]; + if (tokens.refreshToken) { + cookies.push( + serializeCookie(names.refresh, tokens.refreshToken, { + maxAge: REFRESH_MAX_AGE_SEC, + stage, + path: cookiePath(names.refresh), + }), + ); + } + cookies.push( + serializeCookie(names.hint, sessionHintValue(tokens.idToken), { + maxAge: REFRESH_MAX_AGE_SEC, + stage, + httpOnly: false, + }), + ); + cookies.push(clearCookie(names.oauth, stage)); + return cookies; +} + +export function clearedSessionCookies(stage: string): string[] { + const names = cookieNames(stage); + const cookies = [ + clearCookie(names.access, stage), + clearCookie(names.id, stage), + clearCookie(names.refresh, stage), + clearCookie(names.oauth, stage), + clearCookie(names.hint, stage, { httpOnly: false }), + ]; + if (stage !== "local") { + const legacy = cookieNames("local"); + cookies.push( + serializeCookie(legacy.access, "", { maxAge: 0, stage, path: "/" }), + serializeCookie(legacy.id, "", { maxAge: 0, stage, path: "/" }), + serializeCookie(legacy.refresh, "", { maxAge: 0, stage, path: "/" }), + serializeCookie(legacy.oauth, "", { maxAge: 0, stage, path: "/" }), + serializeCookie(legacy.hint, "", { maxAge: 0, stage, path: "/", httpOnly: false }), + ); + } + return cookies; +} + +export function serializeOauthCookie(payload: OauthCookie, stage: string): string { + const names = cookieNames(stage); + return serializeCookie(names.oauth, encodeOauth(payload), { maxAge: OAUTH_MAX_AGE_SEC, stage }); +} + +export function readOauthCookie(event: CookieEvent, stage: string): OauthCookie | undefined { + const raw = sessionCookieValue(event, "oauth", stage); + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.state !== "string" || + !parsed.state || + typeof parsed.verifier !== "string" || + !parsed.verifier || + typeof parsed.returnTo !== "string" + ) { + return undefined; + } + return { state: parsed.state, verifier: parsed.verifier, returnTo: parsed.returnTo }; + } catch { + return undefined; + } +} + +export function headerFrom(event: CookieEvent, name: string): string | undefined { + const headers = event.headers ?? {}; + const needle = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === needle) return value; + } + return undefined; +} + +export function cookieEventFromHeader(cookieHeader: string | undefined): CookieEvent { + return { headers: { cookie: cookieHeader } }; +} + +export function sessionHintFromIdToken(token: string): SessionHint | null { + const parts = token.split("."); + if (parts.length !== 3) return null; + try { + const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record< + string, + unknown + >; + const email = + typeof claims.email === "string" && claims.email.includes("@") ? claims.email : ""; + if (!email) return null; + const displayName = + (typeof claims.name === "string" && claims.name) || + (typeof claims.given_name === "string" && claims.given_name) || + email; + return { email, displayName }; + } catch { + return null; + } +} + +function cookiePath(name: string): string { + if (name.startsWith("__Host-")) return "/"; + return name.endsWith(COOKIE_REFRESH) ? "/api/auth" : "/"; +} + +function sessionHintValue(idToken: string): string { + const hint = sessionHintFromIdToken(idToken); + return hint ? JSON.stringify(hint) : "1"; +} + +function cookieParts(event: CookieEvent): string[] { + const parts: string[] = []; + for (const cookie of event.cookies ?? []) { + parts.push(cookie); + } + const header = headerFrom(event, "cookie"); + if (header) { + for (const part of header.split(";")) { + if (part.trim()) parts.push(part); + } + } + return parts; +} + +function decodeCookieValue(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function encodeOauth(payload: OauthCookie): string { + return JSON.stringify(payload); +} diff --git a/packages/api/src/auth/middleware.ts b/packages/api/src/auth/middleware.ts index 36e10fa..d6d9426 100644 --- a/packages/api/src/auth/middleware.ts +++ b/packages/api/src/auth/middleware.ts @@ -6,6 +6,8 @@ import type { ApiEnv, UserRole } from "../env.js"; import { isUserRole } from "../env.js"; import type { Db } from "../db/client.js"; import { errorJson } from "../http.js"; +import { cookieEventFromHeader, sessionCookieValue } from "./cookies.js"; +import { cookieStage, isPublicRoute } from "./oauth.js"; export type AppVariables = { user: AuthUser; @@ -15,6 +17,12 @@ export type AppBindings = { Variables: AppVariables; }; +export type TokenVerifier = (token: string) => Promise; + +export type AuthDeps = { + verifyToken?: TokenVerifier; +}; + function roleFromClaims(claims: Record, fallback: UserRole): UserRole { const raw = (typeof claims["custom:role"] === "string" && claims["custom:role"]) || @@ -56,13 +64,31 @@ function identityFromPayload(payload: JWTPayload): { return { sub, email, name }; } -export function createAuthMiddleware(env: ApiEnv, handle: Db) { +export function createAuthMiddleware(env: ApiEnv, handle: Db, deps: AuthDeps = {}) { const jwks = env.cognitoIssuer.length > 0 ? createRemoteJWKSet(new URL(`${env.cognitoIssuer}/.well-known/jwks.json`)) : null; + const verifyToken: TokenVerifier = + deps.verifyToken ?? + (async (token) => { + if (!jwks) { + throw new Error("JWT verification is not configured."); + } + const { payload } = await jwtVerify(token, jwks, { + issuer: env.cognitoIssuer, + }); + return payload; + }); + return createMiddleware(async (c, next) => { + const path = new URL(c.req.url).pathname; + if (isPublicRoute(c.req.method, path)) { + await next(); + return; + } + if (env.devAuthBypass) { try { const user = await upsertUserFromIdentity(handle, { @@ -82,21 +108,15 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) { return; } - const header = c.req.header("authorization"); - if (!header?.startsWith("Bearer ")) { - return errorJson(c, 401, "UNAUTHENTICATED", "Missing or invalid Authorization header."); + const stage = cookieStage(env); + const idToken = sessionCookieValue(cookieEventFromHeader(c.req.header("cookie")), "id", stage); + if (!idToken) { + return errorJson(c, 401, "UNAUTHENTICATED", "Missing id token."); } - if (!jwks) { - return errorJson(c, 401, "UNAUTHENTICATED", "JWT verification is not configured."); - } - - const token = header.slice("Bearer ".length); let payload: JWTPayload; try { - ({ payload } = await jwtVerify(token, jwks, { - issuer: env.cognitoIssuer, - })); + payload = await verifyToken(idToken); } catch { return errorJson(c, 401, "UNAUTHENTICATED", "Invalid or expired token."); } diff --git a/packages/api/src/auth/oauth.test.ts b/packages/api/src/auth/oauth.test.ts new file mode 100644 index 0000000..3b18fa9 --- /dev/null +++ b/packages/api/src/auth/oauth.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { cookieStage, safeReturnTo, signinErrorLocation } from "./oauth.js"; + +describe("oauth helpers", () => { + it("honors a relative returnTo and rejects open redirects", () => { + expect(safeReturnTo("/invoices")).toBe("/invoices"); + expect(safeReturnTo("//evil.com")).toBe("/"); + expect(safeReturnTo("/login")).toBe("/"); + expect(safeReturnTo("https://evil.com")).toBe("/"); + expect(safeReturnTo("/invoices?tab=2#top")).toBe("/invoices?tab=2#top"); + expect(signinErrorLocation("/invoices")).toBe("/login?error=1&returnTo=%2Finvoices"); + expect(signinErrorLocation("/")).toBe("/login?error=1"); + }); + + it("uses local cookie names for development and test", () => { + expect(cookieStage({ nodeEnv: "test", stage: "dev" })).toBe("local"); + expect(cookieStage({ nodeEnv: "development", stage: "dev" })).toBe("local"); + expect(cookieStage({ nodeEnv: "production", stage: "dev" })).toBe("dev"); + }); +}); diff --git a/packages/api/src/auth/oauth.ts b/packages/api/src/auth/oauth.ts new file mode 100644 index 0000000..09105eb --- /dev/null +++ b/packages/api/src/auth/oauth.ts @@ -0,0 +1,229 @@ +import type { Context } from "hono"; +import type { ApiEnv } from "../env.js"; +import { errorJson } from "../http.js"; +import { + cookieEventFromHeader, + cookieNames, + clearCookie, + clearedSessionCookies, + readOauthCookie, + serializeOauthCookie, + sessionCookieValue, + tokenCookies, + type CookieEvent, +} from "./cookies.js"; +import { + authorizeUrl, + callbackRedirectUri, + createCognitoTokenClient, + type CognitoTokenClient, +} from "./cognito.js"; +import { createPkce, safeEqual } from "./pkce.js"; + +const LOCAL_ORIGINS = [ + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://127.0.0.1:8787", + "http://localhost:8787", +] as const; + +export function cookieStage(env: Pick): string { + if (env.nodeEnv === "development" || env.nodeEnv === "test" || env.stage === "local") { + return "local"; + } + return env.stage; +} + +export function isPublicRoute(method: string, path: string): boolean { + if (method === "GET" && path === "/api/health") return true; + if (method === "GET" && path === "/api/ready") return true; + if (method === "GET" && path === "/api/auth/login") return true; + if (method === "GET" && path === "/api/auth/callback") return true; + if (method === "POST" && path === "/api/auth/refresh") return true; + if (method === "POST" && path === "/api/auth/logout") return true; + return false; +} + +export function isMutating(method: string): boolean { + return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE"; +} + +export function allowedOrigins(env: Pick): Set { + const origins = new Set(); + if (cookieStage(env) === "local") { + for (const origin of LOCAL_ORIGINS) origins.add(origin); + } + const app = env.appOrigin.replace(/\/$/, ""); + if (app) origins.add(app); + return origins; +} + +export function csrfAllowed( + c: Context, + env: Pick, +): boolean { + const origin = c.req.header("origin")?.trim(); + if (!origin) return false; + return allowedOrigins(env).has(origin); +} + +const RETURN_TO_BASE = "https://return-to.invalid"; + +function hasControlCharacter(value: string): boolean { + for (const ch of value) { + const code = ch.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) return true; + } + return false; +} + +function isPlainAfterDecoding(value: string): boolean { + let current = value; + for (let i = 0; i < 2; i += 1) { + let decoded: string; + try { + decoded = decodeURIComponent(current); + } catch { + return false; + } + if (decoded.includes("\\") || hasControlCharacter(decoded)) return false; + if (decoded === current) break; + current = decoded; + } + return true; +} + +export function safeReturnTo(raw: string | null | undefined): string { + if (!raw) return "/"; + const value = raw.trim(); + if (!value.startsWith("/")) return "/"; + if (value.includes("\\") || hasControlCharacter(value)) return "/"; + if (!isPlainAfterDecoding(value)) return "/"; + let url: URL; + try { + url = new URL(value, RETURN_TO_BASE); + } catch { + return "/"; + } + if (url.origin !== RETURN_TO_BASE) return "/"; + if (url.pathname === "/login") return "/"; + const resolved = `${url.pathname}${url.search}${url.hash}`; + if (!resolved.startsWith("/") || resolved.startsWith("//")) return "/"; + return resolved; +} + +export function signinErrorLocation(returnTo?: string | null): string { + const safe = safeReturnTo(returnTo); + if (safe === "/") return "/login?error=1"; + return `/login?error=1&returnTo=${encodeURIComponent(safe)}`; +} + +export function applyCookies(c: Context, cookies: string[]): void { + for (const cookie of cookies) { + c.header("set-cookie", cookie, { append: true }); + } +} + +function cookieEvent(c: Context): CookieEvent { + return cookieEventFromHeader(c.req.header("cookie")); +} + +export async function handleLogin(c: Context, env: ApiEnv) { + if (!env.cognitoAudience || !env.cognitoDomain) { + return errorJson(c, 500, "INTERNAL_ERROR", "Cognito is not configured."); + } + const returnTo = safeReturnTo(c.req.query("returnTo")); + const pkce = createPkce(); + const location = authorizeUrl({ + domain: env.cognitoDomain, + clientId: env.cognitoAudience, + redirectUri: callbackRedirectUri(env.appOrigin), + state: pkce.state, + challenge: pkce.challenge, + }); + const stage = cookieStage(env); + applyCookies(c, [ + serializeOauthCookie({ state: pkce.state, verifier: pkce.verifier, returnTo }, stage), + ]); + return c.redirect(location, 302); +} + +export async function handleCallback( + c: Context, + env: ApiEnv, + tokens: CognitoTokenClient = createCognitoTokenClient(env), +) { + const stage = cookieStage(env); + const oauth = readOauthCookie(cookieEvent(c), stage); + const fail = () => { + applyCookies(c, [clearCookie(cookieNames(stage).oauth, stage)]); + return c.redirect(signinErrorLocation(oauth?.returnTo), 302); + }; + + if (c.req.query("error")) return fail(); + const code = c.req.query("code")?.trim(); + const state = c.req.query("state")?.trim(); + if (!code || !state || !oauth || !safeEqual(state, oauth.state)) return fail(); + + try { + const exchanged = await tokens.exchangeCode({ + code, + verifier: oauth.verifier, + redirectUri: callbackRedirectUri(env.appOrigin), + }); + if (!exchanged.refreshToken) return fail(); + applyCookies(c, tokenCookies(exchanged, stage)); + return c.redirect(safeReturnTo(oauth.returnTo), 302); + } catch { + return fail(); + } +} + +export async function handleRefresh( + c: Context, + env: ApiEnv, + tokens: CognitoTokenClient = createCognitoTokenClient(env), +) { + const stage = cookieStage(env); + const refreshToken = sessionCookieValue(cookieEvent(c), "refresh", stage); + if (!refreshToken) { + applyCookies(c, clearedSessionCookies(stage)); + return errorJson(c, 401, "UNAUTHENTICATED", "Missing refresh token."); + } + try { + const rotated = await tokens.refresh(refreshToken); + applyCookies( + c, + tokenCookies( + { + accessToken: rotated.accessToken, + idToken: rotated.idToken, + refreshToken: rotated.refreshToken ?? refreshToken, + }, + stage, + ), + ); + return c.body(null, 204); + } catch { + applyCookies(c, clearedSessionCookies(stage)); + return errorJson(c, 401, "UNAUTHENTICATED", "Refresh failed."); + } +} + +export async function handleLogout( + c: Context, + env: ApiEnv, + tokens: CognitoTokenClient = createCognitoTokenClient(env), +) { + const stage = cookieStage(env); + const refreshToken = sessionCookieValue(cookieEvent(c), "refresh", stage); + if (refreshToken && env.cognitoDomain && env.cognitoAudience) { + try { + await tokens.revoke(refreshToken); + } catch { + // Still clear cookies so the browser session ends. + } + } + applyCookies(c, clearedSessionCookies(stage)); + return c.body(null, 204); +} diff --git a/packages/api/src/auth/origin-verify.ts b/packages/api/src/auth/origin-verify.ts new file mode 100644 index 0000000..dfa5516 --- /dev/null +++ b/packages/api/src/auth/origin-verify.ts @@ -0,0 +1,18 @@ +import { timingSafeEqual } from "node:crypto"; +import type { Context } from "hono"; + +export const ORIGIN_VERIFY_HEADER = "x-origin-verify"; + +export function originVerifyHeader(c: Context): string { + return c.req.header(ORIGIN_VERIFY_HEADER)?.trim() ?? ""; +} + +/** When a secret is configured, only CloudFront's origin header is accepted. */ +export function cloudFrontOriginAllowed(c: Context, secret: string | undefined): boolean { + if (!secret) return true; + const provided = originVerifyHeader(c); + const a = Buffer.from(provided); + const b = Buffer.from(secret); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} diff --git a/packages/api/src/auth/pkce.ts b/packages/api/src/auth/pkce.ts new file mode 100644 index 0000000..a63056d --- /dev/null +++ b/packages/api/src/auth/pkce.ts @@ -0,0 +1,23 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; + +const STATE_BYTES = 32; +const VERIFIER_BYTES = 32; + +export function randomToken(bytes = STATE_BYTES): string { + return randomBytes(bytes).toString("base64url"); +} + +export function createPkce(): { verifier: string; challenge: string; state: string } { + const verifier = randomToken(VERIFIER_BYTES); + return { verifier, challenge: pkceChallenge(verifier), state: randomToken() }; +} + +export function pkceChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +export function safeEqual(left: string, right: string): boolean { + const hashedLeft = createHash("sha256").update(left).digest(); + const hashedRight = createHash("sha256").update(right).digest(); + return timingSafeEqual(hashedLeft, hashedRight) && left.length === right.length; +} diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index c3f4dd6..790b799 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -23,6 +23,9 @@ export type ApiEnv = { rdsDatabase: string; cognitoIssuer: string; cognitoAudience: string; + cognitoDomain: string; + appOrigin: string; + originVerifySecret: string; devAuthBypass: boolean; devAuthSub: string; devAuthEmail: string; @@ -73,6 +76,9 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { rdsDatabase: env.RDS_DATABASE ?? "seahaven_ap", cognitoIssuer: env.COGNITO_ISSUER ?? "", cognitoAudience: env.COGNITO_AUDIENCE ?? "", + cognitoDomain: env.COGNITO_DOMAIN?.trim() ?? "", + appOrigin: env.APP_ORIGIN?.trim() || (local ? "http://127.0.0.1:3000" : ""), + originVerifySecret: env.ORIGIN_VERIFY_SECRET?.trim() ?? "", devAuthBypass, devAuthSub: env.DEV_AUTH_SUB ?? "seed-sub-admin", devAuthEmail: env.DEV_AUTH_EMAIL ?? "admin@seahavenind.com", diff --git a/packages/api/src/routes/auth.ts b/packages/api/src/routes/auth.ts new file mode 100644 index 0000000..67196be --- /dev/null +++ b/packages/api/src/routes/auth.ts @@ -0,0 +1,16 @@ +import { Hono } from "hono"; +import type { ApiEnv } from "../env.js"; +import type { CognitoTokenClient } from "../auth/cognito.js"; +import { handleCallback, handleLogin, handleLogout, handleRefresh } from "../auth/oauth.js"; +import type { AppBindings } from "../auth/middleware.js"; + +export function createAuthRoutes(env: ApiEnv, deps: { tokens?: CognitoTokenClient } = {}) { + const routes = new Hono(); + + routes.get("/auth/login", (c) => handleLogin(c, env)); + routes.get("/auth/callback", (c) => handleCallback(c, env, deps.tokens)); + routes.post("/auth/refresh", (c) => handleRefresh(c, env, deps.tokens)); + routes.post("/auth/logout", (c) => handleLogout(c, env, deps.tokens)); + + return routes; +} diff --git a/redocly.yaml b/redocly.yaml index fdee367..6b39d1c 100644 --- a/redocly.yaml +++ b/redocly.yaml @@ -67,6 +67,11 @@ rules: - ready - me - api + - auth + - login + - callback + - refresh + - logout paths-kebab-case: error no-invalid-schema-examples: error # No schema-properties casing rule: property names mirror the DynamoDB From 1c295d854ddd2ba23ab970f3a53d5caa16e56585 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 12:39:44 -0400 Subject: [PATCH 03/31] feat(web): add unused cookie SPA API client Land a credentials-include fetch helper and hand-synced health/me types without wiring pages or domain hooks, so mocks stay the default data path. --- src/api/client.test.ts | 54 ++++++++++++++++++++++++++ src/api/client.ts | 87 ++++++++++++++++++++++++++++++++++++++++++ src/api/types.ts | 28 ++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/api/client.test.ts create mode 100644 src/api/client.ts create mode 100644 src/api/types.ts diff --git a/src/api/client.test.ts b/src/api/client.test.ts new file mode 100644 index 0000000..57ad53e --- /dev/null +++ b/src/api/client.test.ts @@ -0,0 +1,54 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { apiFetch, readApiJson } from "@/api/client"; + +function walk(dir: string): string[] { + const entries = readdirSync(dir); + const files: string[] = []; + for (const entry of entries) { + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + files.push(...walk(full)); + } else if (full.endsWith(".ts") || full.endsWith(".tsx")) { + files.push(full); + } + } + return files; +} + +describe("unused SPA API client", () => { + it("sends credentials and never sets Authorization", async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stage: "local", sha: "x" }))); + vi.stubGlobal("fetch", fetchMock); + await apiFetch("/api/health", { headers: { Authorization: "Bearer leaked" } }); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.credentials).toBe("include"); + const headers = new Headers(init.headers); + expect(headers.get("Authorization")).toBeNull(); + expect(headers.get("Accept")).toBe("application/json"); + vi.unstubAllGlobals(); + }); + + it("is not imported from pages, domain, or mocks", () => { + const roots = ["src/pages", "src/domain", "src/mocks"].map((dir) => join(process.cwd(), dir)); + const hits: string[] = []; + for (const root of roots) { + for (const file of walk(root)) { + const text = readFileSync(file, "utf8"); + if (text.includes("@/api/client") || text.includes("src/api/client")) { + hits.push(file); + } + } + } + expect(hits).toEqual([]); + }); + + it("parses JSON success bodies", async () => { + const body = await readApiJson<{ stage: string }>( + new Response(JSON.stringify({ stage: "local" }), { status: 200 }), + ); + expect(body.stage).toBe("local"); + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..6467cda --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,87 @@ +import type { ErrorEnvelope } from "@/api/types"; + +function pathnameOf(input: RequestInfo | URL): string { + const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + try { + return new URL(raw, "http://local.invalid").pathname; + } catch { + return raw.split("?")[0] ?? raw; + } +} + +function skipRefresh(input: RequestInfo | URL): boolean { + const path = pathnameOf(input); + return ( + path === "/api/auth/login" || + path === "/api/auth/callback" || + path === "/api/auth/refresh" || + path === "/api/auth/logout" + ); +} + +let refreshInFlight: Promise | null = null; + +async function refreshSession(): Promise { + if (!refreshInFlight) { + refreshInFlight = fetch("/api/auth/refresh", { + method: "POST", + credentials: "include", + headers: { Accept: "application/json" }, + }) + .then((response) => response.status === 204) + .catch(() => false) + .finally(() => { + refreshInFlight = null; + }); + } + return refreshInFlight; +} + +export async function apiFetch( + input: RequestInfo | URL, + init: RequestInit = {}, +): Promise { + const headers = new Headers(init.headers); + if (!headers.has("Accept")) headers.set("Accept", "application/json"); + headers.delete("Authorization"); + const requestInit: RequestInit = { ...init, credentials: "include", headers }; + const response = await fetch(input, requestInit); + if ((response.status !== 401 && response.status !== 403) || skipRefresh(input)) { + return response; + } + const refreshed = await refreshSession(); + if (refreshed) return fetch(input, requestInit); + return response; +} + +export class ApiError extends Error { + readonly status: number; + readonly code?: string; + + constructor(message: string, status: number, code?: string) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + } +} + +export async function readApiJson(response: Response): Promise { + const text = await response.text(); + let body: T & Partial; + try { + body = JSON.parse(text) as T & Partial; + } catch { + throw response.ok + ? new Error("Invalid JSON from API") + : new ApiError(`HTTP ${response.status}`, response.status); + } + if (!response.ok) { + throw new ApiError( + body.error?.message || `HTTP ${response.status}`, + response.status, + body.error?.code, + ); + } + return body; +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..e8c1837 --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,28 @@ +export type ErrorEnvelope = { + error: { + code: string; + message: string; + correlationId: string; + }; +}; + +export type HealthResponse = { + stage: string; + sha: string; +}; + +export type MeResponse = { + id: string; + email: string; + name: string; + role: "admin" | "ap_processor" | "approver" | "viewer"; +}; + +export type ApiPaths = { + "/api/health": { + get: { response: HealthResponse }; + }; + "/api/me": { + get: { response: MeResponse }; + }; +}; From ebee62acd023f40a0178058c9963a2bc14e8d575 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 12:44:51 -0400 Subject: [PATCH 04/31] feat(api): add master-data OpenAPI and Hono stubs --- packages/api/openapi/components/schemas.yaml | 116 ++++++++++++ packages/api/openapi/openapi.yaml | 26 +++ .../api/openapi/paths/departments-id.yaml | 82 +++++++++ packages/api/openapi/paths/departments.yaml | 68 +++++++ .../api/openapi/paths/gl-accounts-id.yaml | 82 +++++++++ packages/api/openapi/paths/gl-accounts.yaml | 68 +++++++ packages/api/openapi/paths/users-id.yaml | 81 +++++++++ packages/api/openapi/paths/users.yaml | 24 +++ packages/api/openapi/paths/vendors-id.yaml | 86 +++++++++ packages/api/openapi/paths/vendors.yaml | 72 ++++++++ packages/api/src/app.ts | 8 + packages/api/src/routes/departments.ts | 80 ++++++++ packages/api/src/routes/gl-accounts.ts | 78 ++++++++ packages/api/src/routes/helpers.ts | 49 +++++ packages/api/src/routes/master-data.test.ts | 102 +++++++++++ packages/api/src/routes/users.ts | 61 +++++++ packages/api/src/routes/vendors.ts | 97 ++++++++++ packages/api/src/test/fake-db.ts | 172 ++++++++++++++++++ packages/api/tsconfig.build.json | 2 +- src/api/types.ts | 46 +++++ 20 files changed, 1399 insertions(+), 1 deletion(-) create mode 100644 packages/api/openapi/paths/departments-id.yaml create mode 100644 packages/api/openapi/paths/departments.yaml create mode 100644 packages/api/openapi/paths/gl-accounts-id.yaml create mode 100644 packages/api/openapi/paths/gl-accounts.yaml create mode 100644 packages/api/openapi/paths/users-id.yaml create mode 100644 packages/api/openapi/paths/users.yaml create mode 100644 packages/api/openapi/paths/vendors-id.yaml create mode 100644 packages/api/openapi/paths/vendors.yaml create mode 100644 packages/api/src/routes/departments.ts create mode 100644 packages/api/src/routes/gl-accounts.ts create mode 100644 packages/api/src/routes/helpers.ts create mode 100644 packages/api/src/routes/master-data.test.ts create mode 100644 packages/api/src/routes/users.ts create mode 100644 packages/api/src/routes/vendors.ts create mode 100644 packages/api/src/test/fake-db.ts diff --git a/packages/api/openapi/components/schemas.yaml b/packages/api/openapi/components/schemas.yaml index 994feb7..4d1ea11 100644 --- a/packages/api/openapi/components/schemas.yaml +++ b/packages/api/openapi/components/schemas.yaml @@ -81,3 +81,119 @@ MeResponse: - approver - viewer example: admin +Vendor: + type: object + required: + - id + - name + - defaultPaymentMethod + - createdAt + - updatedAt + properties: + id: + type: string + format: uuid + description: Vendor primary key. + example: 55555555-5555-4555-8555-555555555555 + name: + type: string + description: Vendor display name. + example: Acme Facilities Supply + email: + type: [string, "null"] + description: Billing email when present. + example: billing@acmefacilities.example + defaultPaymentMethod: + type: string + enum: [check, ach] + description: Default payment method for new invoices. + example: check + createdAt: + type: string + format: date-time + description: Row creation time. + updatedAt: + type: string + format: date-time + description: Row update time. +GlAccount: + type: object + required: [id, code, name, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + description: GL account primary key. + example: 66666666-6666-4666-8666-666666666666 + code: + type: string + description: Account code. + example: "6100" + name: + type: string + description: Account name. + example: Facilities Expense + createdAt: + type: string + format: date-time + description: Row creation time. + updatedAt: + type: string + format: date-time + description: Row update time. +Department: + type: object + required: [id, code, name, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + description: Department primary key. + example: 77777777-7777-4777-8777-777777777777 + code: + type: string + description: Department code. + example: OPS + name: + type: string + description: Department name. + example: Operations + createdAt: + type: string + format: date-time + description: Row creation time. + updatedAt: + type: string + format: date-time + description: Row update time. +User: + type: object + required: [id, email, name, role, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + description: User primary key. + example: 11111111-1111-4111-8111-111111111111 + email: + type: string + format: email + description: User email address. + example: admin@seahavenind.com + name: + type: string + description: Display name. + example: Dev Admin + role: + type: string + enum: [admin, ap_processor, approver, viewer] + description: Authorization role. + example: admin + createdAt: + type: string + format: date-time + description: Row creation time. + updatedAt: + type: string + format: date-time + description: Row update time. diff --git a/packages/api/openapi/openapi.yaml b/packages/api/openapi/openapi.yaml index c3fab19..2ecdda1 100644 --- a/packages/api/openapi/openapi.yaml +++ b/packages/api/openapi/openapi.yaml @@ -13,6 +13,8 @@ tags: description: Liveness and readiness checks for the API process. - name: Session description: Cookie session via Cognito hosted UI, plus caller identity after upsert. + - name: Master data + description: Vendors, GL accounts, departments, and user role updates. paths: /api/health: $ref: ./paths/health.yaml @@ -28,6 +30,22 @@ paths: $ref: ./paths/auth-logout.yaml /api/me: $ref: ./paths/me.yaml + /api/vendors: + $ref: ./paths/vendors.yaml + /api/vendors/{id}: + $ref: ./paths/vendors-id.yaml + /api/gl-accounts: + $ref: ./paths/gl-accounts.yaml + /api/gl-accounts/{id}: + $ref: ./paths/gl-accounts-id.yaml + /api/departments: + $ref: ./paths/departments.yaml + /api/departments/{id}: + $ref: ./paths/departments-id.yaml + /api/users: + $ref: ./paths/users.yaml + /api/users/{id}: + $ref: ./paths/users-id.yaml components: securitySchemes: cookieAuth: @@ -41,5 +59,13 @@ components: $ref: ./components/schemas.yaml#/ReadyResponse MeResponse: $ref: ./components/schemas.yaml#/MeResponse + Vendor: + $ref: ./components/schemas.yaml#/Vendor + GlAccount: + $ref: ./components/schemas.yaml#/GlAccount + Department: + $ref: ./components/schemas.yaml#/Department + User: + $ref: ./components/schemas.yaml#/User security: - cookieAuth: [] diff --git a/packages/api/openapi/paths/departments-id.yaml b/packages/api/openapi/paths/departments-id.yaml new file mode 100644 index 0000000..b91b01b --- /dev/null +++ b/packages/api/openapi/paths/departments-id.yaml @@ -0,0 +1,82 @@ +parameters: + - name: id + in: path + required: true + description: Department primary key. + schema: + type: string + format: uuid + example: 77777777-7777-4777-8777-777777777777 +get: + tags: [Master data] + summary: Get a department + description: Returns one department by id. + operationId: get-api-departments-id + responses: + "200": + description: Department. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Department + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Department not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +patch: + tags: [Master data] + summary: Update a department + description: Admin-only patch. Requires admin:settings. + operationId: patch-api-departments-id + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + example: OPS + name: + type: string + example: Operations + responses: + "200": + description: Updated department. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Department + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks admin:settings. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Department not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/departments.yaml b/packages/api/openapi/paths/departments.yaml new file mode 100644 index 0000000..3355b2d --- /dev/null +++ b/packages/api/openapi/paths/departments.yaml @@ -0,0 +1,68 @@ +get: + tags: [Master data] + summary: List departments + description: Returns every department row. + operationId: get-api-departments + responses: + "200": + description: Department list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/Department + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +post: + tags: [Master data] + summary: Create a department + description: Admin-only insert. Requires admin:settings. + operationId: post-api-departments + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code, name] + properties: + code: + type: string + example: FIN + name: + type: string + example: Finance + responses: + "201": + description: Created department. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Department + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks admin:settings. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/gl-accounts-id.yaml b/packages/api/openapi/paths/gl-accounts-id.yaml new file mode 100644 index 0000000..c49d96f --- /dev/null +++ b/packages/api/openapi/paths/gl-accounts-id.yaml @@ -0,0 +1,82 @@ +parameters: + - name: id + in: path + required: true + description: GL account primary key. + schema: + type: string + format: uuid + example: 66666666-6666-4666-8666-666666666666 +get: + tags: [Master data] + summary: Get a GL account + description: Returns one GL account by id. + operationId: get-api-gl-accounts-id + responses: + "200": + description: GL account. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/GlAccount + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: GL account not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +patch: + tags: [Master data] + summary: Update a GL account + description: Admin-only patch. Requires admin:settings. + operationId: patch-api-gl-accounts-id + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + example: "6100" + name: + type: string + example: Facilities Expense + responses: + "200": + description: Updated GL account. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/GlAccount + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks admin:settings. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: GL account not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/gl-accounts.yaml b/packages/api/openapi/paths/gl-accounts.yaml new file mode 100644 index 0000000..8dfb21c --- /dev/null +++ b/packages/api/openapi/paths/gl-accounts.yaml @@ -0,0 +1,68 @@ +get: + tags: [Master data] + summary: List GL accounts + description: Returns every GL account row. + operationId: get-api-gl-accounts + responses: + "200": + description: GL account list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/GlAccount + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +post: + tags: [Master data] + summary: Create a GL account + description: Admin-only insert. Requires admin:settings. + operationId: post-api-gl-accounts + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code, name] + properties: + code: + type: string + example: "6200" + name: + type: string + example: Utilities + responses: + "201": + description: Created GL account. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/GlAccount + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks admin:settings. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/users-id.yaml b/packages/api/openapi/paths/users-id.yaml new file mode 100644 index 0000000..0db0ea1 --- /dev/null +++ b/packages/api/openapi/paths/users-id.yaml @@ -0,0 +1,81 @@ +parameters: + - name: id + in: path + required: true + description: User primary key. + schema: + type: string + format: uuid + example: 11111111-1111-4111-8111-111111111111 +get: + tags: [Master data] + summary: Get a user + description: Returns one user by id. + operationId: get-api-users-id + responses: + "200": + description: User. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/User + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: User not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +patch: + tags: [Master data] + summary: Update a user role + description: Admin-only role update. Does not rebind email across Cognito subjects. + operationId: patch-api-users-id + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role] + properties: + role: + type: string + enum: [admin, ap_processor, approver, viewer] + example: approver + responses: + "200": + description: Updated user. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/User + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks admin:settings. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: User not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/users.yaml b/packages/api/openapi/paths/users.yaml new file mode 100644 index 0000000..1cd0665 --- /dev/null +++ b/packages/api/openapi/paths/users.yaml @@ -0,0 +1,24 @@ +get: + tags: [Master data] + summary: List users + description: Returns every user profile. Identity upsert remains sub-keyed. + operationId: get-api-users + responses: + "200": + description: User list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/User + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/vendors-id.yaml b/packages/api/openapi/paths/vendors-id.yaml new file mode 100644 index 0000000..593afcd --- /dev/null +++ b/packages/api/openapi/paths/vendors-id.yaml @@ -0,0 +1,86 @@ +parameters: + - name: id + in: path + required: true + description: Vendor primary key. + schema: + type: string + format: uuid + example: 55555555-5555-4555-8555-555555555555 +get: + tags: [Master data] + summary: Get a vendor + description: Returns one vendor by id. + operationId: get-api-vendors-id + responses: + "200": + description: Vendor. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Vendor + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Vendor not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +patch: + tags: [Master data] + summary: Update a vendor + description: Admin-only patch. Requires admin:settings. + operationId: patch-api-vendors-id + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + example: Harbor Maintenance LLC + email: + type: string + example: billing@harbor.example + defaultPaymentMethod: + type: string + enum: [check, ach] + example: check + responses: + "200": + description: Updated vendor. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Vendor + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks admin:settings. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Vendor not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/vendors.yaml b/packages/api/openapi/paths/vendors.yaml new file mode 100644 index 0000000..4db6fd3 --- /dev/null +++ b/packages/api/openapi/paths/vendors.yaml @@ -0,0 +1,72 @@ +get: + tags: [Master data] + summary: List vendors + description: Returns every vendor row. + operationId: get-api-vendors + responses: + "200": + description: Vendor list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/Vendor + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +post: + tags: [Master data] + summary: Create a vendor + description: Admin-only insert. Requires admin:settings. + operationId: post-api-vendors + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + example: Harbor Maintenance + email: + type: string + example: billing@harbor.example + defaultPaymentMethod: + type: string + enum: [check, ach] + example: ach + responses: + "201": + description: Created vendor. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Vendor + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks admin:settings. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 83f4b8f..a75016e 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -5,6 +5,10 @@ import { createAuthMiddleware, type AppBindings, type AuthDeps } from "./auth/mi import { createHealthRoutes } from "./routes/health.js"; import { createMeRoutes } from "./routes/me.js"; import { createAuthRoutes } from "./routes/auth.js"; +import { createVendorRoutes } from "./routes/vendors.js"; +import { createGlAccountRoutes } from "./routes/gl-accounts.js"; +import { createDepartmentRoutes } from "./routes/departments.js"; +import { createUserRoutes } from "./routes/users.js"; import { errorJson } from "./http.js"; import { cloudFrontOriginAllowed } from "./auth/origin-verify.js"; import { csrfAllowed, isMutating } from "./auth/oauth.js"; @@ -40,6 +44,10 @@ export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) { api.route("/", createHealthRoutes(env, handle)); api.route("/", createAuthRoutes(env, deps)); api.route("/", createMeRoutes()); + api.route("/", createVendorRoutes(handle)); + api.route("/", createGlAccountRoutes(handle)); + api.route("/", createDepartmentRoutes(handle)); + api.route("/", createUserRoutes(handle)); app.route("/api", api); app.notFound((c) => errorJson(c, 404, "NOT_FOUND", "Not found.")); diff --git a/packages/api/src/routes/departments.ts b/packages/api/src/routes/departments.ts new file mode 100644 index 0000000..d46a625 --- /dev/null +++ b/packages/api/src/routes/departments.ts @@ -0,0 +1,80 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Db } from "../db/client.js"; +import { departments } from "../db/schema/index.js"; +import type { AppBindings } from "../auth/middleware.js"; +import { errorJson } from "../http.js"; +import { asString, iso, isUuid, parseJsonBody, requireCan } from "./helpers.js"; + +function toDepartment(row: typeof departments.$inferSelect) { + return { + id: row.id, + code: row.code, + name: row.name, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }; +} + +export function createDepartmentRoutes(handle: Db) { + const routes = new Hono(); + + routes.get("/departments", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await handle.db.select().from(departments); + return c.json({ items: rows.map(toDepartment) }); + }); + + routes.get("/departments/:id", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid department id."); + const row = await handle.db.query.departments.findFirst({ where: eq(departments.id, id) }); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Department not found."); + return c.json(toDepartment(row)); + }); + + routes.post("/departments", async (c) => { + const denied = requireCan(c, "admin:settings"); + if (denied) return denied; + const body = await parseJsonBody(c); + const code = asString(body.code).trim(); + const name = asString(body.name).trim(); + if (!code || !name) return errorJson(c, 400, "VALIDATION_ERROR", "Code and name are required."); + const [row] = await handle.db.insert(departments).values({ code, name }).returning(); + return c.json(toDepartment(row), 201); + }); + + routes.patch("/departments/:id", async (c) => { + const denied = requireCan(c, "admin:settings"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid department id."); + const existing = await handle.db.query.departments.findFirst({ + where: eq(departments.id, id), + }); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "Department not found."); + const body = await parseJsonBody(c); + const patch: Partial = { updatedAt: new Date() }; + if (body.code !== undefined) { + const code = asString(body.code).trim(); + if (!code) return errorJson(c, 400, "VALIDATION_ERROR", "Code is required."); + patch.code = code; + } + if (body.name !== undefined) { + const name = asString(body.name).trim(); + if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required."); + patch.name = name; + } + const [row] = await handle.db + .update(departments) + .set(patch) + .where(eq(departments.id, id)) + .returning(); + return c.json(toDepartment(row)); + }); + + return routes; +} diff --git a/packages/api/src/routes/gl-accounts.ts b/packages/api/src/routes/gl-accounts.ts new file mode 100644 index 0000000..b889d65 --- /dev/null +++ b/packages/api/src/routes/gl-accounts.ts @@ -0,0 +1,78 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Db } from "../db/client.js"; +import { glAccounts } from "../db/schema/index.js"; +import type { AppBindings } from "../auth/middleware.js"; +import { errorJson } from "../http.js"; +import { asString, iso, isUuid, parseJsonBody, requireCan } from "./helpers.js"; + +function toGlAccount(row: typeof glAccounts.$inferSelect) { + return { + id: row.id, + code: row.code, + name: row.name, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }; +} + +export function createGlAccountRoutes(handle: Db) { + const routes = new Hono(); + + routes.get("/gl-accounts", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await handle.db.select().from(glAccounts); + return c.json({ items: rows.map(toGlAccount) }); + }); + + routes.get("/gl-accounts/:id", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid GL account id."); + const row = await handle.db.query.glAccounts.findFirst({ where: eq(glAccounts.id, id) }); + if (!row) return errorJson(c, 404, "NOT_FOUND", "GL account not found."); + return c.json(toGlAccount(row)); + }); + + routes.post("/gl-accounts", async (c) => { + const denied = requireCan(c, "admin:settings"); + if (denied) return denied; + const body = await parseJsonBody(c); + const code = asString(body.code).trim(); + const name = asString(body.name).trim(); + if (!code || !name) return errorJson(c, 400, "VALIDATION_ERROR", "Code and name are required."); + const [row] = await handle.db.insert(glAccounts).values({ code, name }).returning(); + return c.json(toGlAccount(row), 201); + }); + + routes.patch("/gl-accounts/:id", async (c) => { + const denied = requireCan(c, "admin:settings"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid GL account id."); + const existing = await handle.db.query.glAccounts.findFirst({ where: eq(glAccounts.id, id) }); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "GL account not found."); + const body = await parseJsonBody(c); + const patch: Partial = { updatedAt: new Date() }; + if (body.code !== undefined) { + const code = asString(body.code).trim(); + if (!code) return errorJson(c, 400, "VALIDATION_ERROR", "Code is required."); + patch.code = code; + } + if (body.name !== undefined) { + const name = asString(body.name).trim(); + if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required."); + patch.name = name; + } + const [row] = await handle.db + .update(glAccounts) + .set(patch) + .where(eq(glAccounts.id, id)) + .returning(); + return c.json(toGlAccount(row)); + }); + + return routes; +} diff --git a/packages/api/src/routes/helpers.ts b/packages/api/src/routes/helpers.ts new file mode 100644 index 0000000..c44d41d --- /dev/null +++ b/packages/api/src/routes/helpers.ts @@ -0,0 +1,49 @@ +import { randomUUID } from "node:crypto"; +import type { Context } from "hono"; +import type { UserRole } from "../env.js"; +import { can, type RbacAction } from "../auth/rbac.js"; +import { errorJson } from "../http.js"; +import type { AppBindings } from "../auth/middleware.js"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function isUuid(value: string): boolean { + return UUID_RE.test(value); +} + +export function requireCan(c: Context, action: RbacAction) { + const user = c.get("user"); + if (!can(user.role, action)) { + return errorJson(c, 403, "FORBIDDEN", `Role ${user.role} is not allowed to ${action}.`); + } + return null; +} + +export function caller(c: Context) { + return c.get("user"); +} + +export function iso(value: Date | string): string { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +export function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +export function optionalString(value: unknown): string | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + if (typeof value === "string") return value; + return undefined; +} + +export function newId(): string { + return randomUUID(); +} + +export function parseJsonBody(c: Context): Promise> { + return c.req.json>(); +} + +export type { UserRole }; diff --git a/packages/api/src/routes/master-data.test.ts b/packages/api/src/routes/master-data.test.ts new file mode 100644 index 0000000..94fa80e --- /dev/null +++ b/packages/api/src/routes/master-data.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../app.js"; +import { loadEnv } from "../env.js"; +import type { ErrorEnvelope } from "../http.js"; +import { createFakeDb, emptyStore, SEED } from "../test/fake-db.js"; + +function expectEnvelope(body: unknown, code: string) { + const envelope = body as ErrorEnvelope; + expect(envelope.error.code).toBe(code); +} + +function adminEnv() { + return loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + DEV_AUTH_SUB: SEED.user.cognitoSub, + DEV_AUTH_EMAIL: SEED.user.email, + DEV_AUTH_NAME: SEED.user.name, + DEV_AUTH_ROLE: "admin", + }); +} + +function viewerEnv() { + return loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + DEV_AUTH_SUB: SEED.user.cognitoSub, + DEV_AUTH_EMAIL: SEED.user.email, + DEV_AUTH_NAME: SEED.user.name, + DEV_AUTH_ROLE: "viewer", + }); +} + +describe("master data stubs", () => { + it("lists and gets seeded vendors", async () => { + const app = createApp(adminEnv(), createFakeDb()); + const list = await app.request("/api/vendors"); + expect(list.status).toBe(200); + const listed = (await list.json()) as { items: Array<{ id: string; name: string }> }; + expect(listed.items[0]?.id).toBe(SEED.vendor.id); + const get = await app.request(`/api/vendors/${SEED.vendor.id}`); + expect(get.status).toBe(200); + await expect(get.json()).resolves.toMatchObject({ id: SEED.vendor.id, name: SEED.vendor.name }); + }); + + it("creates a vendor and returns 201", async () => { + const app = createApp(adminEnv(), createFakeDb()); + const response = await app.request("/api/vendors", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:3000" }, + body: JSON.stringify({ name: "Harbor Maintenance", defaultPaymentMethod: "ach" }), + }); + expect(response.status).toBe(201); + await expect(response.json()).resolves.toMatchObject({ + name: "Harbor Maintenance", + defaultPaymentMethod: "ach", + }); + }); + + it("returns 404 for a missing vendor", async () => { + const store = emptyStore(); + store.vendors = []; + const app = createApp(adminEnv(), createFakeDb(store)); + const response = await app.request(`/api/vendors/${SEED.vendor.id}`); + expect(response.status).toBe(404); + expectEnvelope(await response.json(), "NOT_FOUND"); + }); + + it("returns 403 when a non-admin writes vendors", async () => { + const app = createApp(viewerEnv(), createFakeDb()); + const response = await app.request("/api/vendors", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:3000" }, + body: JSON.stringify({ name: "Nope" }), + }); + expect(response.status).toBe(403); + expectEnvelope(await response.json(), "FORBIDDEN"); + }); + + it("lists GL accounts and departments", async () => { + const app = createApp(adminEnv(), createFakeDb()); + const gl = await app.request("/api/gl-accounts"); + expect(gl.status).toBe(200); + const glBody = (await gl.json()) as { items: Array<{ code: string }> }; + expect(glBody.items[0]?.code).toBe("6100"); + const departments = await app.request("/api/departments"); + expect(departments.status).toBe(200); + const deptBody = (await departments.json()) as { items: Array<{ code: string }> }; + expect(deptBody.items[0]?.code).toBe("OPS"); + }); + + it("updates a user role", async () => { + const app = createApp(adminEnv(), createFakeDb()); + const response = await app.request(`/api/users/${SEED.user.id}`, { + method: "PATCH", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:3000" }, + body: JSON.stringify({ role: "approver" }), + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ role: "approver" }); + }); +}); diff --git a/packages/api/src/routes/users.ts b/packages/api/src/routes/users.ts new file mode 100644 index 0000000..7e2e1a9 --- /dev/null +++ b/packages/api/src/routes/users.ts @@ -0,0 +1,61 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Db } from "../db/client.js"; +import { users } from "../db/schema/index.js"; +import type { AppBindings } from "../auth/middleware.js"; +import { errorJson } from "../http.js"; +import { iso, isUuid, parseJsonBody, requireCan } from "./helpers.js"; +import { isUserRole } from "../env.js"; + +function toUser(row: typeof users.$inferSelect) { + return { + id: row.id, + email: row.email, + name: row.name, + role: row.role, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }; +} + +export function createUserRoutes(handle: Db) { + const routes = new Hono(); + + routes.get("/users", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await handle.db.select().from(users); + return c.json({ items: rows.map(toUser) }); + }); + + routes.get("/users/:id", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid user id."); + const row = await handle.db.query.users.findFirst({ where: eq(users.id, id) }); + if (!row) return errorJson(c, 404, "NOT_FOUND", "User not found."); + return c.json(toUser(row)); + }); + + routes.patch("/users/:id", async (c) => { + const denied = requireCan(c, "admin:settings"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid user id."); + const existing = await handle.db.query.users.findFirst({ where: eq(users.id, id) }); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "User not found."); + const body = await parseJsonBody(c); + if (typeof body.role !== "string" || !isUserRole(body.role)) { + return errorJson(c, 400, "VALIDATION_ERROR", "A valid role is required."); + } + const [row] = await handle.db + .update(users) + .set({ role: body.role, updatedAt: new Date() }) + .where(eq(users.id, id)) + .returning(); + return c.json(toUser(row)); + }); + + return routes; +} diff --git a/packages/api/src/routes/vendors.ts b/packages/api/src/routes/vendors.ts new file mode 100644 index 0000000..8d819ff --- /dev/null +++ b/packages/api/src/routes/vendors.ts @@ -0,0 +1,97 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Db } from "../db/client.js"; +import { vendors } from "../db/schema/index.js"; +import type { AppBindings } from "../auth/middleware.js"; +import { errorJson } from "../http.js"; +import { asString, iso, isUuid, optionalString, parseJsonBody, requireCan } from "./helpers.js"; + +const PAYMENT_METHODS = ["check", "ach"] as const; +type PaymentMethod = (typeof PAYMENT_METHODS)[number]; + +function isPaymentMethod(value: string): value is PaymentMethod { + return (PAYMENT_METHODS as readonly string[]).includes(value); +} + +function toVendor(row: typeof vendors.$inferSelect) { + return { + id: row.id, + name: row.name, + email: row.email, + defaultPaymentMethod: row.defaultPaymentMethod, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }; +} + +export function createVendorRoutes(handle: Db) { + const routes = new Hono(); + + routes.get("/vendors", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await handle.db.select().from(vendors); + return c.json({ items: rows.map(toVendor) }); + }); + + routes.get("/vendors/:id", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendor id."); + const row = await handle.db.query.vendors.findFirst({ where: eq(vendors.id, id) }); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Vendor not found."); + return c.json(toVendor(row)); + }); + + routes.post("/vendors", async (c) => { + const denied = requireCan(c, "admin:settings"); + if (denied) return denied; + const body = await parseJsonBody(c); + const name = asString(body.name).trim(); + if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required."); + const method = asString(body.defaultPaymentMethod, "check"); + if (!isPaymentMethod(method)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid defaultPaymentMethod."); + } + const [row] = await handle.db + .insert(vendors) + .values({ + name, + email: optionalString(body.email) ?? null, + defaultPaymentMethod: method, + }) + .returning(); + return c.json(toVendor(row), 201); + }); + + routes.patch("/vendors/:id", async (c) => { + const denied = requireCan(c, "admin:settings"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendor id."); + const existing = await handle.db.query.vendors.findFirst({ where: eq(vendors.id, id) }); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "Vendor not found."); + const body = await parseJsonBody(c); + const patch: Partial = { updatedAt: new Date() }; + if (body.name !== undefined) { + const name = asString(body.name).trim(); + if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required."); + patch.name = name; + } + if (body.email !== undefined) { + patch.email = optionalString(body.email) ?? null; + } + if (body.defaultPaymentMethod !== undefined) { + const method = asString(body.defaultPaymentMethod); + if (!isPaymentMethod(method)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid defaultPaymentMethod."); + } + patch.defaultPaymentMethod = method; + } + const [row] = await handle.db.update(vendors).set(patch).where(eq(vendors.id, id)).returning(); + return c.json(toVendor(row)); + }); + + return routes; +} diff --git a/packages/api/src/test/fake-db.ts b/packages/api/src/test/fake-db.ts new file mode 100644 index 0000000..eaae40e --- /dev/null +++ b/packages/api/src/test/fake-db.ts @@ -0,0 +1,172 @@ +import { getTableName } from "drizzle-orm"; +import { vi } from "vitest"; +import type { Db } from "../db/client.js"; +import type { UserRole } from "../env.js"; + +export const SEED = { + user: { + id: "11111111-1111-4111-8111-111111111111", + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + role: "admin" as UserRole, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + vendor: { + id: "55555555-5555-4555-8555-555555555555", + name: "Acme Facilities Supply", + email: "billing@acmefacilities.example", + defaultPaymentMethod: "check" as const, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + gl: { + id: "66666666-6666-4666-8666-666666666666", + code: "6100", + name: "Facilities Expense", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + department: { + id: "77777777-7777-4777-8777-777777777777", + code: "OPS", + name: "Operations", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, +}; + +export type Store = { + users: Array; + vendors: Array; + glAccounts: Array; + departments: Array; + invoices: Array>; + invoiceLines: Array>; + documents: Array>; + approvalPolicies: Array>; + approvalSteps: Array>; + invoiceComments: Array>; + activityLog: Array>; +}; + +export function emptyStore(): Store { + return { + users: [{ ...SEED.user }], + vendors: [{ ...SEED.vendor }], + glAccounts: [{ ...SEED.gl }], + departments: [{ ...SEED.department }], + invoices: [], + invoiceLines: [], + documents: [], + approvalPolicies: [], + approvalSteps: [], + invoiceComments: [], + activityLog: [], + }; +} + +function rowsFor(store: Store, table: unknown): Array> { + switch (getTableName(table as never)) { + case "users": + return store.users; + case "vendors": + return store.vendors; + case "gl_accounts": + return store.glAccounts; + case "departments": + return store.departments; + case "invoices": + return store.invoices; + case "invoice_lines": + return store.invoiceLines; + case "documents": + return store.documents; + case "approval_policies": + return store.approvalPolicies; + case "approval_steps": + return store.approvalSteps; + case "invoice_comments": + return store.invoiceComments; + case "activity_log": + return store.activityLog; + default: + return []; + } +} + +function findById(rows: Array>, id: string) { + return rows.find((row) => row.id === id) ?? null; +} + +export function createFakeDb(store: Store = emptyStore()): Db { + const queryFind = (tableName: keyof Store) => ({ + findFirst: vi.fn(async (opts?: { where?: unknown }) => { + const rows = store[tableName] as Array>; + if (!opts) return rows[0] ?? null; + // Routes always look up by primary key; return the first seeded row or null via tests mutating store. + return rows[0] ?? null; + }), + findMany: vi.fn(async () => store[tableName]), + }); + + const db = { + execute: vi.fn(async () => []), + select: vi.fn(() => ({ + from: vi.fn(async (table: unknown) => rowsFor(store, table)), + })), + query: { + users: queryFind("users"), + vendors: queryFind("vendors"), + glAccounts: queryFind("glAccounts"), + departments: queryFind("departments"), + invoices: queryFind("invoices"), + invoiceLines: queryFind("invoiceLines"), + documents: queryFind("documents"), + approvalPolicies: queryFind("approvalPolicies"), + approvalSteps: queryFind("approvalSteps"), + invoiceComments: queryFind("invoiceComments"), + activityLog: queryFind("activityLog"), + }, + insert: vi.fn((table: unknown) => ({ + values: vi.fn((value: Record) => ({ + returning: vi.fn(async () => { + const now = new Date(); + const row = { + id: typeof value.id === "string" ? value.id : crypto.randomUUID(), + createdAt: now, + updatedAt: now, + ...value, + }; + rowsFor(store, table).push(row); + return [row]; + }), + })), + })), + update: vi.fn((table: unknown) => ({ + set: vi.fn((patch: Record) => ({ + where: vi.fn(() => ({ + returning: vi.fn(async () => { + const rows = rowsFor(store, table); + const current = rows[0]; + if (!current) return []; + Object.assign(current, patch); + return [current]; + }), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(async () => undefined), + })), + }; + + return { + driver: "postgres", + pool: { end: vi.fn(async () => undefined) } as never, + db: db as never, + }; +} + +export { findById }; diff --git a/packages/api/tsconfig.build.json b/packages/api/tsconfig.build.json index cf0eba4..fc94193 100644 --- a/packages/api/tsconfig.build.json +++ b/packages/api/tsconfig.build.json @@ -3,5 +3,5 @@ "compilerOptions": { "noEmit": false }, - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/test/**"] } diff --git a/src/api/types.ts b/src/api/types.ts index e8c1837..510f9c5 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -18,6 +18,40 @@ export type MeResponse = { role: "admin" | "ap_processor" | "approver" | "viewer"; }; +export type Vendor = { + id: string; + name: string; + email: string | null; + defaultPaymentMethod: "check" | "ach"; + createdAt: string; + updatedAt: string; +}; + +export type GlAccount = { + id: string; + code: string; + name: string; + createdAt: string; + updatedAt: string; +}; + +export type Department = { + id: string; + code: string; + name: string; + createdAt: string; + updatedAt: string; +}; + +export type User = { + id: string; + email: string; + name: string; + role: MeResponse["role"]; + createdAt: string; + updatedAt: string; +}; + export type ApiPaths = { "/api/health": { get: { response: HealthResponse }; @@ -25,4 +59,16 @@ export type ApiPaths = { "/api/me": { get: { response: MeResponse }; }; + "/api/vendors": { + get: { response: { items: Vendor[] } }; + }; + "/api/gl-accounts": { + get: { response: { items: GlAccount[] } }; + }; + "/api/departments": { + get: { response: { items: Department[] } }; + }; + "/api/users": { + get: { response: { items: User[] } }; + }; }; From f08b538a191d5702bd0b3a0f0c8c03aade849b78 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 12:48:42 -0400 Subject: [PATCH 05/31] feat(api): add invoice, line, and document stubs --- package-lock.json | 74 +++ packages/api/openapi/components/schemas.yaml | 155 ++++++ packages/api/openapi/openapi.yaml | 22 + .../paths/documents-id-confirmations.yaml | 52 ++ packages/api/openapi/paths/documents-id.yaml | 39 ++ .../openapi/paths/invoices-id-documents.yaml | 53 +++ .../api/openapi/paths/invoices-id-lines.yaml | 123 +++++ packages/api/openapi/paths/invoices-id.yaml | 106 +++++ packages/api/openapi/paths/invoices.yaml | 106 +++++ packages/api/package.json | 2 + packages/api/src/app.ts | 4 + packages/api/src/documents.ts | 77 +++ packages/api/src/env.ts | 9 + packages/api/src/routes/helpers.ts | 41 ++ packages/api/src/routes/invoices.test.ts | 163 +++++++ packages/api/src/routes/invoices.ts | 446 ++++++++++++++++++ packages/api/src/test/fake-db.ts | 52 +- src/api/types.ts | 46 ++ 18 files changed, 1562 insertions(+), 8 deletions(-) create mode 100644 packages/api/openapi/paths/documents-id-confirmations.yaml create mode 100644 packages/api/openapi/paths/documents-id.yaml create mode 100644 packages/api/openapi/paths/invoices-id-documents.yaml create mode 100644 packages/api/openapi/paths/invoices-id-lines.yaml create mode 100644 packages/api/openapi/paths/invoices-id.yaml create mode 100644 packages/api/openapi/paths/invoices.yaml create mode 100644 packages/api/src/documents.ts create mode 100644 packages/api/src/routes/invoices.test.ts create mode 100644 packages/api/src/routes/invoices.ts diff --git a/package-lock.json b/package-lock.json index d100c79..1bc709e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,6 +113,22 @@ "node": "20 || >=22" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1001.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.0.tgz", + "integrity": "sha512-6uTniZc87q+B5eXouGTl+7Tmc482rEeCcvxpsvREP8EfF0gvloRZ41UOA9sbSJlyy8TbqIBXb3kKfKarEArUQA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-rds-data": { "version": "3.1136.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-rds-data/-/client-rds-data-3.1136.0.tgz", @@ -132,6 +148,28 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1137.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1137.0.tgz", + "integrity": "sha512-ppiYnDyy2qCDT3PIV83XJwAi0BhVUZ/jOHxUgC5tlMCaFeKRL8PVVub8A0pUMkF1yvZtzNOp3ClbmTCcIa9w3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1001.0", + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/credential-provider-node": "^3.972.83", + "@aws-sdk/middleware-sdk-s3": "^3.972.76", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { "version": "3.978.0", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.0.tgz", @@ -299,6 +337,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.76.tgz", + "integrity": "sha512-NfnTkVUTBKTBuBgqaapFK9r3YdkKt1b2oRvgLzZq91bwNKh6ZS0S7sEcheguttREaL4iyfs/xQnqD7Z7AsWSsA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/nested-clients": { "version": "3.997.45", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.45.tgz", @@ -318,6 +373,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1137.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1137.0.tgz", + "integrity": "sha512-OJQwS0qt5fQMoZSccncZQBLLvSZ3Jw7Lo+E3MYBNNPRnUkvJxn5LeY246K+1QvoL9o8zHpYVPa7YgCL+zf+xtg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/signature-v4-multi-region": { "version": "3.996.46", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", @@ -7816,6 +7888,8 @@ "version": "0.1.0", "dependencies": { "@aws-sdk/client-rds-data": "^3.1135.0", + "@aws-sdk/client-s3": "^3.1137.0", + "@aws-sdk/s3-request-presigner": "^3.1137.0", "@hono/node-server": "^2.1.1", "@seahaven-ap/shared": "*", "drizzle-orm": "^0.45.2", diff --git a/packages/api/openapi/components/schemas.yaml b/packages/api/openapi/components/schemas.yaml index 4d1ea11..bd6be25 100644 --- a/packages/api/openapi/components/schemas.yaml +++ b/packages/api/openapi/components/schemas.yaml @@ -197,3 +197,158 @@ User: type: string format: date-time description: Row update time. +Invoice: + type: object + required: + - id + - vendorId + - invoiceNumber + - amount + - amountDue + - dueDate + - status + - paymentMethod + - memo + - createdAt + - updatedAt + - lines + properties: + id: + type: string + format: uuid + description: Invoice primary key. + example: 88888888-8888-4888-8888-888888888888 + vendorId: + type: string + format: uuid + description: Vendor foreign key. + example: 55555555-5555-4555-8555-555555555555 + invoiceNumber: + type: string + description: Vendor-issued invoice number. + example: INV-1001 + amount: + type: string + description: Invoice total as numeric(14,2) text. + example: "1250.00" + amountDue: + type: string + description: Remaining amount due as numeric(14,2) text. + example: "1250.00" + dueDate: + type: string + description: Due date as YYYY-MM-DD. + example: "2026-09-01" + payDate: + type: [string, "null"] + description: Optional pay date as YYYY-MM-DD. + example: "2026-09-15" + sendPaymentOn: + type: [string, "null"] + description: Optional send date as YYYY-MM-DD. + example: "2026-09-10" + status: + type: string + enum: [pending_approval, approved, scheduled, paid, rejected, void] + description: Invoice workflow status. + example: pending_approval + paymentMethod: + type: string + enum: [check, ach] + description: Payment method for this invoice. + example: check + memo: + type: string + description: Free-form memo. + example: Seed invoice for local smoke. + createdAt: + type: string + format: date-time + description: Row creation time. + updatedAt: + type: string + format: date-time + description: Row update time. + lines: + type: array + description: Coding lines attached to the invoice. + items: + $ref: "#/InvoiceLine" +InvoiceLine: + type: object + required: [id, invoiceId, description, amount, createdAt] + properties: + id: + type: string + format: uuid + description: Line primary key. + example: 99999999-9999-4999-8999-999999999999 + invoiceId: + type: string + format: uuid + description: Parent invoice id. + example: 88888888-8888-4888-8888-888888888888 + description: + type: string + description: Line description. + example: Monthly maintenance + amount: + type: string + description: Line amount as numeric(14,2) text. + example: "1250.00" + glAccountId: + type: [string, "null"] + format: uuid + description: Optional GL account id. + departmentId: + type: [string, "null"] + format: uuid + description: Optional department id. + createdAt: + type: string + format: date-time + description: Row creation time. +Document: + type: object + required: [id, objectKey, contentType, fileName, createdAt] + properties: + id: + type: string + format: uuid + description: Document primary key. + example: dddddddd-dddd-4ddd-8ddd-dddddddddddd + invoiceId: + type: [string, "null"] + format: uuid + description: Parent invoice id when attached. + objectKey: + type: string + description: Object key in the documents bucket. + example: seed/inv-1001.pdf + contentType: + type: string + description: Uploaded object MIME type. + example: application/pdf + fileName: + type: string + description: Original file name. + example: inv-1001.pdf + uploadedByUserId: + type: [string, "null"] + format: uuid + description: User who started the upload. + createdAt: + type: string + format: date-time + description: Row creation time. + uploadUrl: + type: string + description: Presigned PUT URL returned on create. + uploadHeaders: + type: object + additionalProperties: + type: string + description: Headers the browser must send with the presigned PUT. + downloadUrl: + type: string + description: Presigned GET URL returned on confirm or get. diff --git a/packages/api/openapi/openapi.yaml b/packages/api/openapi/openapi.yaml index 2ecdda1..f0881a3 100644 --- a/packages/api/openapi/openapi.yaml +++ b/packages/api/openapi/openapi.yaml @@ -15,6 +15,10 @@ tags: description: Cookie session via Cognito hosted UI, plus caller identity after upsert. - name: Master data description: Vendors, GL accounts, departments, and user role updates. + - name: Invoices + description: Invoice headers, coding lines, and uniqueness rules. + - name: Documents + description: Presigned document upload, confirm, and download against MinIO or S3. paths: /api/health: $ref: ./paths/health.yaml @@ -46,6 +50,18 @@ paths: $ref: ./paths/users.yaml /api/users/{id}: $ref: ./paths/users-id.yaml + /api/invoices: + $ref: ./paths/invoices.yaml + /api/invoices/{id}: + $ref: ./paths/invoices-id.yaml + /api/invoices/{id}/lines: + $ref: ./paths/invoices-id-lines.yaml + /api/invoices/{id}/documents: + $ref: ./paths/invoices-id-documents.yaml + /api/documents/{id}: + $ref: ./paths/documents-id.yaml + /api/documents/{id}/confirmations: + $ref: ./paths/documents-id-confirmations.yaml components: securitySchemes: cookieAuth: @@ -67,5 +83,11 @@ components: $ref: ./components/schemas.yaml#/Department User: $ref: ./components/schemas.yaml#/User + Invoice: + $ref: ./components/schemas.yaml#/Invoice + InvoiceLine: + $ref: ./components/schemas.yaml#/InvoiceLine + Document: + $ref: ./components/schemas.yaml#/Document security: - cookieAuth: [] diff --git a/packages/api/openapi/paths/documents-id-confirmations.yaml b/packages/api/openapi/paths/documents-id-confirmations.yaml new file mode 100644 index 0000000..046f263 --- /dev/null +++ b/packages/api/openapi/paths/documents-id-confirmations.yaml @@ -0,0 +1,52 @@ +parameters: + - name: id + in: path + required: true + description: Document primary key. + schema: + type: string + format: uuid + example: dddddddd-dddd-4ddd-8ddd-dddddddddddd +post: + tags: [Documents] + summary: Confirm a document upload + description: Succeeds when the object exists in MinIO or S3. Missing objects are a conflict. + operationId: post-api-documents-id-confirmations + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + responses: + "200": + description: Confirmed document with download URL. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Document + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Document not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "409": + description: Object has not been uploaded. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/documents-id.yaml b/packages/api/openapi/paths/documents-id.yaml new file mode 100644 index 0000000..62e0bdd --- /dev/null +++ b/packages/api/openapi/paths/documents-id.yaml @@ -0,0 +1,39 @@ +parameters: + - name: id + in: path + required: true + description: Document primary key. + schema: + type: string + format: uuid + example: dddddddd-dddd-4ddd-8ddd-dddddddddddd +get: + tags: [Documents] + summary: Get a document + description: Returns document metadata and a presigned GET URL. + operationId: get-api-documents-id + responses: + "200": + description: Document with download URL. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Document + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Document not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/invoices-id-documents.yaml b/packages/api/openapi/paths/invoices-id-documents.yaml new file mode 100644 index 0000000..039a40b --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-documents.yaml @@ -0,0 +1,53 @@ +parameters: + - name: id + in: path + required: true + description: Invoice primary key. + schema: + type: string + format: uuid + example: 88888888-8888-4888-8888-888888888888 +post: + tags: [Documents] + summary: Presign a document upload + description: Creates a document row and returns a MinIO or S3 presigned PUT URL. + operationId: post-api-invoices-id-documents + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [fileName] + properties: + fileName: + type: string + example: inv-1001.pdf + contentType: + type: string + example: application/pdf + responses: + "201": + description: Document row with upload URL. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Document + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/invoices-id-lines.yaml b/packages/api/openapi/paths/invoices-id-lines.yaml new file mode 100644 index 0000000..af59da5 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-lines.yaml @@ -0,0 +1,123 @@ +parameters: + - name: id + in: path + required: true + description: Invoice primary key. + schema: + type: string + format: uuid + example: 88888888-8888-4888-8888-888888888888 +post: + tags: [Invoices] + summary: Add an invoice line + description: Appends one coding line. Sum is checked on replace, not on this add. + operationId: post-api-invoices-id-lines + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [description, amount] + properties: + description: + type: string + example: Extra coding + amount: + type: string + example: "25.00" + glAccountId: + type: string + format: uuid + example: 66666666-6666-4666-8666-666666666666 + departmentId: + type: string + format: uuid + example: 77777777-7777-4777-8777-777777777777 + responses: + "201": + description: Created line. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/InvoiceLine + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +put: + tags: [Invoices] + summary: Replace invoice lines + description: Replaces every coding line. The amounts must sum to the invoice amount. + operationId: put-api-invoices-id-lines + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + type: object + required: [description, amount] + properties: + description: + type: string + example: Labor + amount: + type: string + example: "1250.00" + glAccountId: + type: string + format: uuid + departmentId: + type: string + format: uuid + responses: + "200": + description: Replaced lines. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/InvoiceLine + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/invoices-id.yaml b/packages/api/openapi/paths/invoices-id.yaml new file mode 100644 index 0000000..a79fe43 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id.yaml @@ -0,0 +1,106 @@ +parameters: + - name: id + in: path + required: true + description: Invoice primary key. + schema: + type: string + format: uuid + example: 88888888-8888-4888-8888-888888888888 +get: + tags: [Invoices] + summary: Get an invoice + description: Returns one invoice and its coding lines. + operationId: get-api-invoices-id + responses: + "200": + description: Invoice. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Invoice + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +patch: + tags: [Invoices] + summary: Update an invoice + description: Patch header fields. Duplicate active vendor plus invoice number is a conflict. + operationId: patch-api-invoices-id + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + vendorId: + type: string + format: uuid + example: 55555555-5555-4555-8555-555555555555 + invoiceNumber: + type: string + example: INV-1001 + amount: + type: string + example: "1250.00" + dueDate: + type: string + example: "2026-09-01" + status: + type: string + enum: [pending_approval, approved, scheduled, paid, rejected, void] + example: approved + paymentMethod: + type: string + enum: [check, ach] + example: ach + memo: + type: string + example: Updated memo + responses: + "200": + description: Updated invoice. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Invoice + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "409": + description: Active vendor and invoice number already exist. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/invoices.yaml b/packages/api/openapi/paths/invoices.yaml new file mode 100644 index 0000000..1e45e53 --- /dev/null +++ b/packages/api/openapi/paths/invoices.yaml @@ -0,0 +1,106 @@ +get: + tags: [Invoices] + summary: List invoices + description: Returns invoices with their coding lines. + operationId: get-api-invoices + responses: + "200": + description: Invoice list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/Invoice + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +post: + tags: [Invoices] + summary: Create an invoice + description: Inserts an invoice. Line amounts must sum to amount when lines are sent. Duplicate active vendor plus invoice number is a conflict. + operationId: post-api-invoices + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [vendorId, invoiceNumber, amount, dueDate] + properties: + vendorId: + type: string + format: uuid + example: 55555555-5555-4555-8555-555555555555 + invoiceNumber: + type: string + example: INV-2002 + amount: + type: string + example: "100.00" + dueDate: + type: string + example: "2026-10-01" + paymentMethod: + type: string + enum: [check, ach] + example: check + memo: + type: string + example: Harbor repair + lines: + type: array + items: + type: object + required: [description, amount] + properties: + description: + type: string + example: Labor + amount: + type: string + example: "100.00" + glAccountId: + type: string + format: uuid + departmentId: + type: string + format: uuid + responses: + "201": + description: Created invoice. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Invoice + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks write:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "409": + description: Active vendor and invoice number already exist. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/package.json b/packages/api/package.json index 0ef41fd..1a19141 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -31,6 +31,8 @@ }, "dependencies": { "@aws-sdk/client-rds-data": "^3.1135.0", + "@aws-sdk/client-s3": "^3.1137.0", + "@aws-sdk/s3-request-presigner": "^3.1137.0", "@hono/node-server": "^2.1.1", "@seahaven-ap/shared": "*", "drizzle-orm": "^0.45.2", diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index a75016e..455b8f5 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -9,6 +9,8 @@ import { createVendorRoutes } from "./routes/vendors.js"; import { createGlAccountRoutes } from "./routes/gl-accounts.js"; import { createDepartmentRoutes } from "./routes/departments.js"; import { createUserRoutes } from "./routes/users.js"; +import { createInvoiceRoutes } from "./routes/invoices.js"; +import { createDocumentsStore, type DocumentsStore } from "./documents.js"; import { errorJson } from "./http.js"; import { cloudFrontOriginAllowed } from "./auth/origin-verify.js"; import { csrfAllowed, isMutating } from "./auth/oauth.js"; @@ -16,6 +18,7 @@ import type { CognitoTokenClient } from "./auth/cognito.js"; export type AppDeps = AuthDeps & { tokens?: CognitoTokenClient; + documents?: DocumentsStore; }; export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) { @@ -48,6 +51,7 @@ export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) { api.route("/", createGlAccountRoutes(handle)); api.route("/", createDepartmentRoutes(handle)); api.route("/", createUserRoutes(handle)); + api.route("/", createInvoiceRoutes(handle, deps.documents ?? createDocumentsStore(env))); app.route("/api", api); app.notFound((c) => errorJson(c, 404, "NOT_FOUND", "Not found.")); diff --git a/packages/api/src/documents.ts b/packages/api/src/documents.ts new file mode 100644 index 0000000..0ad0b99 --- /dev/null +++ b/packages/api/src/documents.ts @@ -0,0 +1,77 @@ +import { + GetObjectCommand, + HeadObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import type { ApiEnv } from "./env.js"; + +export type PresignPutResult = { + url: string; + headers: Record; +}; + +export type DocumentsStore = { + presignPut(input: { objectKey: string; contentType: string }): Promise; + objectExists(objectKey: string): Promise; + presignGet(objectKey: string): Promise; +}; + +const SIGN_EXPIRES_SECONDS = 900; + +export function createDocumentsStore(env: ApiEnv): DocumentsStore { + const client = new S3Client({ + region: env.awsRegion, + endpoint: env.documentsEndpoint || undefined, + forcePathStyle: Boolean(env.documentsEndpoint), + credentials: + env.documentsAccessKey && env.documentsSecretKey + ? { accessKeyId: env.documentsAccessKey, secretAccessKey: env.documentsSecretKey } + : undefined, + }); + const bucket = env.documentsBucket; + + return { + async presignPut({ objectKey, contentType }) { + const url = await getSignedUrl( + client, + new PutObjectCommand({ Bucket: bucket, Key: objectKey, ContentType: contentType }), + { expiresIn: SIGN_EXPIRES_SECONDS }, + ); + return { url, headers: { "Content-Type": contentType } }; + }, + async objectExists(objectKey) { + try { + await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey })); + return true; + } catch { + return false; + } + }, + async presignGet(objectKey) { + return getSignedUrl(client, new GetObjectCommand({ Bucket: bucket, Key: objectKey }), { + expiresIn: SIGN_EXPIRES_SECONDS, + }); + }, + }; +} + +export function createMemoryDocumentsStore(): DocumentsStore & { uploaded: Set } { + const uploaded = new Set(); + return { + uploaded, + async presignPut({ objectKey, contentType }) { + return { + url: `http://127.0.0.1:9000/seahaven-ap-documents/${objectKey}?presign=put`, + headers: { "Content-Type": contentType }, + }; + }, + async objectExists(objectKey) { + return uploaded.has(objectKey); + }, + async presignGet(objectKey) { + return `http://127.0.0.1:9000/seahaven-ap-documents/${objectKey}?presign=get`; + }, + }; +} diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 790b799..c9475f4 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -26,6 +26,10 @@ export type ApiEnv = { cognitoDomain: string; appOrigin: string; originVerifySecret: string; + documentsEndpoint: string; + documentsAccessKey: string; + documentsSecretKey: string; + documentsBucket: string; devAuthBypass: boolean; devAuthSub: string; devAuthEmail: string; @@ -79,6 +83,11 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { cognitoDomain: env.COGNITO_DOMAIN?.trim() ?? "", appOrigin: env.APP_ORIGIN?.trim() || (local ? "http://127.0.0.1:3000" : ""), originVerifySecret: env.ORIGIN_VERIFY_SECRET?.trim() ?? "", + documentsEndpoint: env.DOCUMENTS_ENDPOINT?.trim() || env.MINIO_ENDPOINT?.trim() || "", + documentsAccessKey: env.DOCUMENTS_ACCESS_KEY?.trim() || env.MINIO_ACCESS_KEY?.trim() || "", + documentsSecretKey: env.DOCUMENTS_SECRET_KEY?.trim() || env.MINIO_SECRET_KEY?.trim() || "", + documentsBucket: + env.DOCUMENTS_BUCKET?.trim() || env.MINIO_BUCKET?.trim() || "seahaven-ap-documents", devAuthBypass, devAuthSub: env.DEV_AUTH_SUB ?? "seed-sub-admin", devAuthEmail: env.DEV_AUTH_EMAIL ?? "admin@seahavenind.com", diff --git a/packages/api/src/routes/helpers.ts b/packages/api/src/routes/helpers.ts index c44d41d..d12dfd1 100644 --- a/packages/api/src/routes/helpers.ts +++ b/packages/api/src/routes/helpers.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { Context } from "hono"; +import type { Db } from "../db/client.js"; import type { UserRole } from "../env.js"; import { can, type RbacAction } from "../auth/rbac.js"; import { errorJson } from "../http.js"; @@ -46,4 +47,44 @@ export function parseJsonBody(c: Context): Promise> { return c.req.json>(); } +export function asMoney(value: unknown): string | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value.toFixed(2); + } + if (typeof value === "string" && /^\d+(\.\d{1,2})?$/.test(value.trim())) { + return Number(value).toFixed(2); + } + return null; +} + +export function moneyCents(value: string): number { + return Math.round(Number(value) * 100); +} + +export function isDateOnly(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(value); +} + +export async function rowsOf(handle: Db, table: unknown): Promise { + return (await handle.db.select().from(table as never)) as T[]; +} + +export async function firstById( + handle: Db, + table: unknown, + id: string, +): Promise { + const rows = await rowsOf(handle, table); + return rows.find((row) => row.id === id); +} + +export function isUniqueViolation(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code: unknown }).code === "23505" + ); +} + export type { UserRole }; diff --git a/packages/api/src/routes/invoices.test.ts b/packages/api/src/routes/invoices.test.ts new file mode 100644 index 0000000..23489a4 --- /dev/null +++ b/packages/api/src/routes/invoices.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../app.js"; +import { createMemoryDocumentsStore } from "../documents.js"; +import { loadEnv } from "../env.js"; +import type { ErrorEnvelope } from "../http.js"; +import { createFakeDb, SEED } from "../test/fake-db.js"; + +function expectEnvelope(body: unknown, code: string) { + const envelope = body as ErrorEnvelope; + expect(envelope.error.code).toBe(code); +} + +function envFor(role: "admin" | "viewer") { + return loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + DEV_AUTH_SUB: SEED.user.cognitoSub, + DEV_AUTH_EMAIL: SEED.user.email, + DEV_AUTH_NAME: SEED.user.name, + DEV_AUTH_ROLE: role, + }); +} + +const jsonHeaders = { + "content-type": "application/json", + origin: "http://127.0.0.1:3000", +}; + +describe("invoice stubs", () => { + it("lists the seeded invoice", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices"); + expect(response.status).toBe(200); + const body = (await response.json()) as { items: Array<{ invoiceNumber: string }> }; + expect(body.items[0]?.invoiceNumber).toBe("INV-1001"); + }); + + it("creates an invoice when line amounts sum to the header amount", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-2002", + amount: "100.00", + dueDate: "2026-10-01", + lines: [ + { description: "Half", amount: "40.00" }, + { description: "Rest", amount: "60.00" }, + ], + }), + }); + expect(response.status).toBe(201); + await expect(response.json()).resolves.toMatchObject({ + invoiceNumber: "INV-2002", + amount: "100.00", + }); + }); + + it("rejects a duplicate active vendor and invoice number", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-1001", + amount: "10.00", + dueDate: "2026-10-01", + }), + }); + expect(response.status).toBe(409); + expectEnvelope(await response.json(), "CONFLICT"); + }); + + it("rejects a line replace whose amounts do not sum to the invoice", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}/lines`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ + items: [{ description: "Too small", amount: "1.00" }], + }), + }); + expect(response.status).toBe(400); + expectEnvelope(await response.json(), "VALIDATION_ERROR"); + }); + + it("replaces lines when the amounts sum to the invoice", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}/lines`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ + items: [ + { description: "Labor", amount: "1000.00", glAccountId: SEED.gl.id }, + { description: "Parts", amount: "250.00", departmentId: SEED.department.id }, + ], + }), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { items: Array<{ amount: string }> }; + expect(body.items).toHaveLength(2); + }); + + it("presigns a document and confirms after upload", async () => { + const documents = createMemoryDocumentsStore(); + const app = createApp(envFor("admin"), createFakeDb(), { documents }); + const created = await app.request(`/api/invoices/${SEED.invoice.id}/documents`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ fileName: "inv.pdf", contentType: "application/pdf" }), + }); + expect(created.status).toBe(201); + const doc = (await created.json()) as { id: string; objectKey: string; uploadUrl: string }; + expect(doc.uploadUrl).toContain("presign=put"); + const missing = await app.request(`/api/documents/${doc.id}/confirmations`, { + method: "POST", + headers: jsonHeaders, + body: "{}", + }); + expect(missing.status).toBe(409); + documents.uploaded.add(doc.objectKey); + const confirmed = await app.request(`/api/documents/${doc.id}/confirmations`, { + method: "POST", + headers: jsonHeaders, + body: "{}", + }); + expect(confirmed.status).toBe(200); + const got = await app.request(`/api/documents/${doc.id}`); + expect(got.status).toBe(200); + await expect(got.json()).resolves.toMatchObject({ id: doc.id, fileName: "inv.pdf" }); + }); + + it("returns 403 when a viewer writes an invoice", async () => { + const app = createApp(envFor("viewer"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-9", + amount: "1.00", + dueDate: "2026-10-01", + }), + }); + expect(response.status).toBe(403); + expectEnvelope(await response.json(), "FORBIDDEN"); + }); +}); diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts new file mode 100644 index 0000000..87bf05b --- /dev/null +++ b/packages/api/src/routes/invoices.ts @@ -0,0 +1,446 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Db } from "../db/client.js"; +import type { DocumentsStore } from "../documents.js"; +import { documents, invoiceLines, invoices, vendors } from "../db/schema/index.js"; +import type { AppBindings } from "../auth/middleware.js"; +import { errorJson } from "../http.js"; +import { + asMoney, + asString, + caller, + firstById, + isDateOnly, + isUniqueViolation, + iso, + isUuid, + moneyCents, + newId, + optionalString, + parseJsonBody, + requireCan, + rowsOf, +} from "./helpers.js"; + +const STATUSES = ["pending_approval", "approved", "scheduled", "paid", "rejected", "void"] as const; +const PAYMENT_METHODS = ["check", "ach"] as const; + +type InvoiceRow = typeof invoices.$inferSelect; +type LineRow = typeof invoiceLines.$inferSelect; +type DocumentRow = typeof documents.$inferSelect; +type VendorRow = typeof vendors.$inferSelect; +type LineInput = { + description: string; + amount: string; + glAccountId: string | null; + departmentId: string | null; +}; + +function isStatus(value: string): value is (typeof STATUSES)[number] { + return (STATUSES as readonly string[]).includes(value); +} + +function isPaymentMethod(value: string): value is (typeof PAYMENT_METHODS)[number] { + return (PAYMENT_METHODS as readonly string[]).includes(value); +} + +function toInvoice(row: InvoiceRow, lines: LineRow[]) { + return { + id: row.id, + vendorId: row.vendorId, + invoiceNumber: row.invoiceNumber, + amount: row.amount, + amountDue: row.amountDue, + dueDate: row.dueDate, + payDate: row.payDate, + sendPaymentOn: row.sendPaymentOn, + status: row.status, + paymentMethod: row.paymentMethod, + memo: row.memo, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + lines: lines.map(toLine), + }; +} + +function toLine(row: LineRow) { + return { + id: row.id, + invoiceId: row.invoiceId, + description: row.description, + amount: row.amount, + glAccountId: row.glAccountId, + departmentId: row.departmentId, + createdAt: iso(row.createdAt), + }; +} + +function toDocument( + row: DocumentRow, + urls: { uploadUrl?: string; uploadHeaders?: Record; downloadUrl?: string } = {}, +) { + return { + id: row.id, + invoiceId: row.invoiceId, + objectKey: row.objectKey, + contentType: row.contentType, + fileName: row.fileName, + uploadedByUserId: row.uploadedByUserId, + createdAt: iso(row.createdAt), + ...urls, + }; +} + +function parseLine(body: Record): LineInput | string { + const description = asString(body.description).trim(); + const amount = asMoney(body.amount); + if (!description || !amount) return "Line description and amount are required."; + const glAccountId = optionalString(body.glAccountId) ?? null; + const departmentId = optionalString(body.departmentId) ?? null; + if (glAccountId && !isUuid(glAccountId)) return "Invalid glAccountId."; + if (departmentId && !isUuid(departmentId)) return "Invalid departmentId."; + return { description, amount, glAccountId, departmentId }; +} + +function linesSumToAmount(lines: Array<{ amount: string }>, amount: string): boolean { + const sum = lines.reduce((total, line) => total + moneyCents(line.amount), 0); + return sum === moneyCents(amount); +} + +async function linesFor(handle: Db, invoiceId: string): Promise { + const rows = await rowsOf(handle, invoiceLines); + return rows.filter((row) => row.invoiceId === invoiceId); +} + +async function activeDuplicate( + handle: Db, + vendorId: string, + invoiceNumber: string, + exceptId?: string, +): Promise { + const rows = await rowsOf(handle, invoices); + return rows.some( + (row) => + row.vendorId === vendorId && + row.invoiceNumber === invoiceNumber && + row.status !== "void" && + row.id !== exceptId, + ); +} + +function safeFileName(value: string): string { + return ( + value + .replace(/[/\\]+/g, "_") + .replace(/^\.+/, "_") + .slice(0, 180) || "document" + ); +} + +export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { + const routes = new Hono(); + + routes.get("/invoices", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await rowsOf(handle, invoices); + const allLines = await rowsOf(handle, invoiceLines); + return c.json({ + items: rows.map((row) => + toInvoice( + row, + allLines.filter((line) => line.invoiceId === row.id), + ), + ), + }); + }); + + routes.get("/invoices/:id", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const row = await firstById(handle, invoices, id); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + return c.json(toInvoice(row, await linesFor(handle, id))); + }); + + routes.post("/invoices", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const body = await parseJsonBody(c); + const vendorId = asString(body.vendorId); + const invoiceNumber = asString(body.invoiceNumber).trim(); + const amount = asMoney(body.amount); + const dueDate = asString(body.dueDate); + if (!isUuid(vendorId) || !invoiceNumber || !amount || !isDateOnly(dueDate)) { + return errorJson( + c, + 400, + "VALIDATION_ERROR", + "vendorId, invoiceNumber, amount, and dueDate are required.", + ); + } + const vendor = await firstById(handle, vendors, vendorId); + if (!vendor) return errorJson(c, 400, "VALIDATION_ERROR", "Vendor not found."); + const method = asString(body.paymentMethod, vendor.defaultPaymentMethod); + if (!isPaymentMethod(method)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid paymentMethod."); + } + const parsedLines: LineInput[] = []; + if (Array.isArray(body.lines)) { + for (const raw of body.lines) { + if (!raw || typeof raw !== "object") { + return errorJson(c, 400, "VALIDATION_ERROR", "Each line must be an object."); + } + const parsed = parseLine(raw as Record); + if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed); + parsedLines.push(parsed); + } + if (!linesSumToAmount(parsedLines, amount)) { + return errorJson( + c, + 400, + "VALIDATION_ERROR", + "Line amounts must sum to the invoice amount.", + ); + } + } + if (await activeDuplicate(handle, vendorId, invoiceNumber)) { + return errorJson( + c, + 409, + "CONFLICT", + "An active invoice already uses this vendor and invoice number.", + ); + } + let row: InvoiceRow; + try { + [row] = await handle.db + .insert(invoices) + .values({ + vendorId, + invoiceNumber, + amount, + amountDue: amount, + dueDate, + paymentMethod: method, + memo: asString(body.memo), + status: "pending_approval", + }) + .returning(); + } catch (error) { + if (isUniqueViolation(error)) { + return errorJson( + c, + 409, + "CONFLICT", + "An active invoice already uses this vendor and invoice number.", + ); + } + throw error; + } + for (const line of parsedLines) { + await handle.db + .insert(invoiceLines) + .values({ invoiceId: row.id, ...line }) + .returning(); + } + return c.json(toInvoice(row, await linesFor(handle, row.id)), 201); + }); + + routes.patch("/invoices/:id", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const existing = await firstById(handle, invoices, id); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const body = await parseJsonBody(c); + const patch: Partial = { updatedAt: new Date() }; + if (body.vendorId !== undefined) { + const vendorId = asString(body.vendorId); + if (!isUuid(vendorId)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendorId."); + patch.vendorId = vendorId; + } + if (body.invoiceNumber !== undefined) { + const invoiceNumber = asString(body.invoiceNumber).trim(); + if (!invoiceNumber) + return errorJson(c, 400, "VALIDATION_ERROR", "invoiceNumber is required."); + patch.invoiceNumber = invoiceNumber; + } + if (body.amount !== undefined) { + const amount = asMoney(body.amount); + if (!amount) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid amount."); + patch.amount = amount; + patch.amountDue = amount; + } + if (body.dueDate !== undefined) { + const dueDate = asString(body.dueDate); + if (!isDateOnly(dueDate)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid dueDate."); + patch.dueDate = dueDate; + } + if (body.payDate !== undefined) { + const payDate = optionalString(body.payDate) ?? null; + if (payDate && !isDateOnly(payDate)) + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid payDate."); + patch.payDate = payDate; + } + if (body.sendPaymentOn !== undefined) { + const sendPaymentOn = optionalString(body.sendPaymentOn) ?? null; + if (sendPaymentOn && !isDateOnly(sendPaymentOn)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid sendPaymentOn."); + } + patch.sendPaymentOn = sendPaymentOn; + } + if (body.status !== undefined) { + const status = asString(body.status); + if (!isStatus(status)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid status."); + patch.status = status; + } + if (body.paymentMethod !== undefined) { + const method = asString(body.paymentMethod); + if (!isPaymentMethod(method)) + return errorJson(c, 400, "VALIDATION_ERROR", "Invalid paymentMethod."); + patch.paymentMethod = method; + } + if (body.memo !== undefined) patch.memo = asString(body.memo); + const nextVendor = patch.vendorId ?? existing.vendorId; + const nextNumber = patch.invoiceNumber ?? existing.invoiceNumber; + const nextStatus = patch.status ?? existing.status; + if (nextStatus !== "void" && (await activeDuplicate(handle, nextVendor, nextNumber, id))) { + return errorJson( + c, + 409, + "CONFLICT", + "An active invoice already uses this vendor and invoice number.", + ); + } + const nextAmount = patch.amount ?? existing.amount; + const currentLines = await linesFor(handle, id); + if (currentLines.length > 0 && !linesSumToAmount(currentLines, nextAmount)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount."); + } + let row: InvoiceRow; + try { + [row] = await handle.db.update(invoices).set(patch).where(eq(invoices.id, id)).returning(); + } catch (error) { + if (isUniqueViolation(error)) { + return errorJson( + c, + 409, + "CONFLICT", + "An active invoice already uses this vendor and invoice number.", + ); + } + throw error; + } + return c.json(toInvoice(row, currentLines)); + }); + + routes.post("/invoices/:id/lines", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const invoice = await firstById(handle, invoices, id); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const parsed = parseLine(await parseJsonBody(c)); + if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed); + const [row] = await handle.db + .insert(invoiceLines) + .values({ invoiceId: id, ...parsed }) + .returning(); + return c.json(toLine(row), 201); + }); + + routes.put("/invoices/:id/lines", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const invoice = await firstById(handle, invoices, id); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const body = await parseJsonBody(c); + if (!Array.isArray(body.items)) { + return errorJson(c, 400, "VALIDATION_ERROR", "items must be an array of lines."); + } + const parsedLines: LineInput[] = []; + for (const raw of body.items) { + if (!raw || typeof raw !== "object") { + return errorJson(c, 400, "VALIDATION_ERROR", "Each line must be an object."); + } + const parsed = parseLine(raw as Record); + if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed); + parsedLines.push(parsed); + } + if (!linesSumToAmount(parsedLines, invoice.amount)) { + return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount."); + } + await handle.db.delete(invoiceLines).where(eq(invoiceLines.invoiceId, id)); + const created: LineRow[] = []; + for (const line of parsedLines) { + const [row] = await handle.db + .insert(invoiceLines) + .values({ invoiceId: id, ...line }) + .returning(); + created.push(row); + } + return c.json({ items: created.map(toLine) }); + }); + + routes.post("/invoices/:id/documents", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const invoice = await firstById(handle, invoices, id); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const body = await parseJsonBody(c); + const fileName = safeFileName(asString(body.fileName).trim()); + const contentType = asString(body.contentType, "application/pdf").trim() || "application/pdf"; + if (!asString(body.fileName).trim()) { + return errorJson(c, 400, "VALIDATION_ERROR", "fileName is required."); + } + const documentId = newId(); + const objectKey = `invoices/${id}/${documentId}/${fileName}`; + const [row] = await handle.db + .insert(documents) + .values({ + id: documentId, + invoiceId: id, + objectKey, + contentType, + fileName, + uploadedByUserId: caller(c).id, + }) + .returning(); + const put = await store.presignPut({ objectKey, contentType }); + return c.json(toDocument(row, { uploadUrl: put.url, uploadHeaders: put.headers }), 201); + }); + + routes.post("/documents/:id/confirmations", async (c) => { + const denied = requireCan(c, "write:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid document id."); + const row = await firstById(handle, documents, id); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Document not found."); + if (!(await store.objectExists(row.objectKey))) { + return errorJson(c, 409, "CONFLICT", "Object has not been uploaded."); + } + return c.json(toDocument(row, { downloadUrl: await store.presignGet(row.objectKey) })); + }); + + routes.get("/documents/:id", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid document id."); + const row = await firstById(handle, documents, id); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Document not found."); + return c.json(toDocument(row, { downloadUrl: await store.presignGet(row.objectKey) })); + }); + + return routes; +} diff --git a/packages/api/src/test/fake-db.ts b/packages/api/src/test/fake-db.ts index eaae40e..b9d10f4 100644 --- a/packages/api/src/test/fake-db.ts +++ b/packages/api/src/test/fake-db.ts @@ -35,6 +35,39 @@ export const SEED = { createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), }, + invoice: { + id: "88888888-8888-4888-8888-888888888888", + vendorId: "55555555-5555-4555-8555-555555555555", + invoiceNumber: "INV-1001", + amount: "1250.00", + amountDue: "1250.00", + dueDate: "2026-09-01", + payDate: null as string | null, + sendPaymentOn: null as string | null, + status: "pending_approval" as const, + paymentMethod: "check" as const, + memo: "Seed invoice for local smoke.", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + line: { + id: "99999999-9999-4999-8999-999999999999", + invoiceId: "88888888-8888-4888-8888-888888888888", + description: "Monthly maintenance", + amount: "1250.00", + glAccountId: "66666666-6666-4666-8666-666666666666", + departmentId: "77777777-7777-4777-8777-777777777777", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, + document: { + id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + invoiceId: "88888888-8888-4888-8888-888888888888", + objectKey: "seed/inv-1001.pdf", + contentType: "application/pdf", + fileName: "inv-1001.pdf", + uploadedByUserId: "11111111-1111-4111-8111-111111111111", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, }; export type Store = { @@ -42,9 +75,9 @@ export type Store = { vendors: Array; glAccounts: Array; departments: Array; - invoices: Array>; - invoiceLines: Array>; - documents: Array>; + invoices: Array; + invoiceLines: Array; + documents: Array; approvalPolicies: Array>; approvalSteps: Array>; invoiceComments: Array>; @@ -57,9 +90,9 @@ export function emptyStore(): Store { vendors: [{ ...SEED.vendor }], glAccounts: [{ ...SEED.gl }], departments: [{ ...SEED.department }], - invoices: [], - invoiceLines: [], - documents: [], + invoices: [{ ...SEED.invoice }], + invoiceLines: [{ ...SEED.line }], + documents: [{ ...SEED.document }], approvalPolicies: [], approvalSteps: [], invoiceComments: [], @@ -157,8 +190,11 @@ export function createFakeDb(store: Store = emptyStore()): Db { })), })), })), - delete: vi.fn(() => ({ - where: vi.fn(async () => undefined), + delete: vi.fn((table: unknown) => ({ + where: vi.fn(async () => { + const rows = rowsFor(store, table); + rows.splice(0, rows.length); + }), })), }; diff --git a/src/api/types.ts b/src/api/types.ts index 510f9c5..b4e3f46 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -52,6 +52,46 @@ export type User = { updatedAt: string; }; +export type InvoiceLine = { + id: string; + invoiceId: string; + description: string; + amount: string; + glAccountId: string | null; + departmentId: string | null; + createdAt: string; +}; + +export type Invoice = { + id: string; + vendorId: string; + invoiceNumber: string; + amount: string; + amountDue: string; + dueDate: string; + payDate: string | null; + sendPaymentOn: string | null; + status: "pending_approval" | "approved" | "scheduled" | "paid" | "rejected" | "void"; + paymentMethod: "check" | "ach"; + memo: string; + createdAt: string; + updatedAt: string; + lines: InvoiceLine[]; +}; + +export type Document = { + id: string; + invoiceId: string | null; + objectKey: string; + contentType: string; + fileName: string; + uploadedByUserId: string | null; + createdAt: string; + uploadUrl?: string; + uploadHeaders?: Record; + downloadUrl?: string; +}; + export type ApiPaths = { "/api/health": { get: { response: HealthResponse }; @@ -71,4 +111,10 @@ export type ApiPaths = { "/api/users": { get: { response: { items: User[] } }; }; + "/api/invoices": { + get: { response: { items: Invoice[] } }; + }; + "/api/documents/{id}": { + get: { response: Document }; + }; }; From 9ae54d4a2a863fae76313cb5411e4418045ab6dd Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 12:51:50 -0400 Subject: [PATCH 06/31] feat(api): add approval policy, inbox, and activity stubs --- packages/api/openapi/components/schemas.yaml | 132 ++++++++ packages/api/openapi/openapi.yaml | 22 ++ .../openapi/paths/approval-policies-id.yaml | 91 ++++++ .../api/openapi/paths/approval-policies.yaml | 71 +++++ .../paths/approval-steps-id-decisions.yaml | 57 ++++ packages/api/openapi/paths/inbox.yaml | 24 ++ .../paths/invoices-id-activity-logs.yaml | 45 +++ .../openapi/paths/invoices-id-comments.yaml | 80 +++++ packages/api/src/app.ts | 2 + packages/api/src/approvals.ts | 74 +++++ packages/api/src/routes/approvals.test.ts | 110 +++++++ packages/api/src/routes/approvals.ts | 285 ++++++++++++++++++ packages/api/src/routes/invoices.ts | 6 +- packages/api/src/test/fake-db.ts | 55 +++- redocly.yaml | 1 + src/api/types.ts | 3 + 16 files changed, 1047 insertions(+), 11 deletions(-) create mode 100644 packages/api/openapi/paths/approval-policies-id.yaml create mode 100644 packages/api/openapi/paths/approval-policies.yaml create mode 100644 packages/api/openapi/paths/approval-steps-id-decisions.yaml create mode 100644 packages/api/openapi/paths/inbox.yaml create mode 100644 packages/api/openapi/paths/invoices-id-activity-logs.yaml create mode 100644 packages/api/openapi/paths/invoices-id-comments.yaml create mode 100644 packages/api/src/approvals.ts create mode 100644 packages/api/src/routes/approvals.test.ts create mode 100644 packages/api/src/routes/approvals.ts diff --git a/packages/api/openapi/components/schemas.yaml b/packages/api/openapi/components/schemas.yaml index bd6be25..98c2009 100644 --- a/packages/api/openapi/components/schemas.yaml +++ b/packages/api/openapi/components/schemas.yaml @@ -352,3 +352,135 @@ Document: downloadUrl: type: string description: Presigned GET URL returned on confirm or get. +ApprovalPolicy: + type: object + required: [id, name, priority, active, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + description: Policy primary key. + example: cccccccc-cccc-4ccc-8ccc-cccccccccccc + name: + type: string + description: Policy display name. + example: Default approver policy + priority: + type: integer + description: Lower numbers match first. + example: 10 + amountThreshold: + type: [string, "null"] + description: Minimum invoice amount that matches this policy. + example: "0.00" + skipBelowAmount: + type: [string, "null"] + description: Amounts below this skip the approval step. + example: "25.00" + active: + type: boolean + description: Whether the policy is considered when matching. + example: true + createdAt: + type: string + format: date-time + description: Row creation time. + updatedAt: + type: string + format: date-time + description: Row update time. +ApprovalStep: + type: object + required: [id, invoiceId, stepOrder, approverRole, status, createdAt] + properties: + id: + type: string + format: uuid + description: Step primary key. + example: eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee + invoiceId: + type: string + format: uuid + description: Parent invoice id. + policyId: + type: [string, "null"] + format: uuid + description: Policy that created the step. + stepOrder: + type: integer + description: Order within the invoice. + example: 1 + approverRole: + type: string + enum: [admin, ap_processor, approver, viewer] + description: Role allowed to act when no assignee is set. + example: approver + assigneeUserId: + type: [string, "null"] + format: uuid + description: Optional assigned user. + status: + type: string + enum: [pending, approved, rejected, skipped] + description: Step status. + example: pending + actedByUserId: + type: [string, "null"] + format: uuid + description: User who acted. + actedAt: + type: [string, "null"] + format: date-time + description: When the step was acted on. + createdAt: + type: string + format: date-time + description: Row creation time. +InvoiceComment: + type: object + required: [id, invoiceId, body, createdAt] + properties: + id: + type: string + format: uuid + description: Comment primary key. + invoiceId: + type: string + format: uuid + description: Parent invoice id. + authorUserId: + type: [string, "null"] + format: uuid + description: Author user id. + body: + type: string + description: Comment text. + example: Looks good. + createdAt: + type: string + format: date-time + description: Row creation time. +ActivityLog: + type: object + required: [id, invoiceId, message, createdAt] + properties: + id: + type: string + format: uuid + description: Activity row primary key. + invoiceId: + type: string + format: uuid + description: Parent invoice id. + actorUserId: + type: [string, "null"] + format: uuid + description: Actor user id. + message: + type: string + description: Activity message. + example: Step approved. + createdAt: + type: string + format: date-time + description: Row creation time. diff --git a/packages/api/openapi/openapi.yaml b/packages/api/openapi/openapi.yaml index f0881a3..4e9d19d 100644 --- a/packages/api/openapi/openapi.yaml +++ b/packages/api/openapi/openapi.yaml @@ -19,6 +19,8 @@ tags: description: Invoice headers, coding lines, and uniqueness rules. - name: Documents description: Presigned document upload, confirm, and download against MinIO or S3. + - name: Approvals + description: Approval policies, step decisions, inbox, comments, and activity. paths: /api/health: $ref: ./paths/health.yaml @@ -62,6 +64,18 @@ paths: $ref: ./paths/documents-id.yaml /api/documents/{id}/confirmations: $ref: ./paths/documents-id-confirmations.yaml + /api/approval-policies: + $ref: ./paths/approval-policies.yaml + /api/approval-policies/{id}: + $ref: ./paths/approval-policies-id.yaml + /api/approval-steps/{id}/decisions: + $ref: ./paths/approval-steps-id-decisions.yaml + /api/inbox: + $ref: ./paths/inbox.yaml + /api/invoices/{id}/comments: + $ref: ./paths/invoices-id-comments.yaml + /api/invoices/{id}/activity-logs: + $ref: ./paths/invoices-id-activity-logs.yaml components: securitySchemes: cookieAuth: @@ -89,5 +103,13 @@ components: $ref: ./components/schemas.yaml#/InvoiceLine Document: $ref: ./components/schemas.yaml#/Document + ApprovalPolicy: + $ref: ./components/schemas.yaml#/ApprovalPolicy + ApprovalStep: + $ref: ./components/schemas.yaml#/ApprovalStep + InvoiceComment: + $ref: ./components/schemas.yaml#/InvoiceComment + ActivityLog: + $ref: ./components/schemas.yaml#/ActivityLog security: - cookieAuth: [] diff --git a/packages/api/openapi/paths/approval-policies-id.yaml b/packages/api/openapi/paths/approval-policies-id.yaml new file mode 100644 index 0000000..e0378f8 --- /dev/null +++ b/packages/api/openapi/paths/approval-policies-id.yaml @@ -0,0 +1,91 @@ +parameters: + - name: id + in: path + required: true + description: Policy primary key. + schema: + type: string + format: uuid + example: cccccccc-cccc-4ccc-8ccc-cccccccccccc +get: + tags: [Approvals] + summary: Get an approval policy + description: Returns one approval policy by id. + operationId: get-api-approval-policies-id + responses: + "200": + description: Policy. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/ApprovalPolicy + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Policy not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +patch: + tags: [Approvals] + summary: Update an approval policy + description: Admin-only patch. Requires admin:settings. + operationId: patch-api-approval-policies-id + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + example: Default approver policy + priority: + type: integer + example: 10 + amountThreshold: + type: string + example: "0.00" + skipBelowAmount: + type: string + example: "25.00" + active: + type: boolean + example: true + responses: + "200": + description: Updated policy. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/ApprovalPolicy + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks admin:settings. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Policy not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/approval-policies.yaml b/packages/api/openapi/paths/approval-policies.yaml new file mode 100644 index 0000000..bac3711 --- /dev/null +++ b/packages/api/openapi/paths/approval-policies.yaml @@ -0,0 +1,71 @@ +get: + tags: [Approvals] + summary: List approval policies + description: Returns every approval policy. + operationId: get-api-approval-policies + responses: + "200": + description: Policy list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/ApprovalPolicy + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +post: + tags: [Approvals] + summary: Create an approval policy + description: Admin-only insert. Requires admin:settings. + operationId: post-api-approval-policies + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + example: High dollar + priority: + type: integer + example: 1 + amountThreshold: + type: string + example: "500.00" + skipBelowAmount: + type: string + example: "25.00" + active: + type: boolean + example: true + responses: + "201": + description: Created policy. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/ApprovalPolicy + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks admin:settings. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/approval-steps-id-decisions.yaml b/packages/api/openapi/paths/approval-steps-id-decisions.yaml new file mode 100644 index 0000000..457a467 --- /dev/null +++ b/packages/api/openapi/paths/approval-steps-id-decisions.yaml @@ -0,0 +1,57 @@ +parameters: + - name: id + in: path + required: true + description: Approval step primary key. + schema: + type: string + format: uuid + example: eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee +post: + tags: [Approvals] + summary: Record an approval decision + description: Approves, rejects, or skips a pending step. Requires approve:invoices. + operationId: post-api-approval-steps-id-decisions + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [action] + properties: + action: + type: string + enum: [approve, reject, skip] + example: approve + responses: + "200": + description: Updated step. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/ApprovalStep + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "403": + description: Caller lacks approve:invoices. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Step not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "409": + description: Step is not pending. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/inbox.yaml b/packages/api/openapi/paths/inbox.yaml new file mode 100644 index 0000000..b6ae046 --- /dev/null +++ b/packages/api/openapi/paths/inbox.yaml @@ -0,0 +1,24 @@ +get: + tags: [Approvals] + summary: List the caller approval inbox + description: Returns pending steps visible to the caller. + operationId: get-api-inbox + responses: + "200": + description: Inbox items. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/ApprovalStep + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/invoices-id-activity-logs.yaml b/packages/api/openapi/paths/invoices-id-activity-logs.yaml new file mode 100644 index 0000000..6ddd237 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-activity-logs.yaml @@ -0,0 +1,45 @@ +parameters: + - name: id + in: path + required: true + description: Invoice primary key. + schema: + type: string + format: uuid + example: 88888888-8888-4888-8888-888888888888 +get: + tags: [Approvals] + summary: List invoice activity + description: Returns activity log rows for one invoice. + operationId: get-api-invoices-id-activity-logs + responses: + "200": + description: Activity list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/ActivityLog + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/openapi/paths/invoices-id-comments.yaml b/packages/api/openapi/paths/invoices-id-comments.yaml new file mode 100644 index 0000000..e8d4a36 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-comments.yaml @@ -0,0 +1,80 @@ +parameters: + - name: id + in: path + required: true + description: Invoice primary key. + schema: + type: string + format: uuid + example: 88888888-8888-4888-8888-888888888888 +get: + tags: [Approvals] + summary: List invoice comments + description: Returns comments on one invoice. + operationId: get-api-invoices-id-comments + responses: + "200": + description: Comment list. + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: ../components/schemas.yaml#/InvoiceComment + "400": + description: Invalid id. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "401": + description: Missing session. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error +post: + tags: [Approvals] + summary: Add an invoice comment + description: Appends a comment and an activity row. + operationId: post-api-invoices-id-comments + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [body] + properties: + body: + type: string + example: Looks good. + responses: + "201": + description: Created comment. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/InvoiceComment + "400": + description: Validation failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + "404": + description: Invoice not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 455b8f5..eb647df 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -10,6 +10,7 @@ import { createGlAccountRoutes } from "./routes/gl-accounts.js"; import { createDepartmentRoutes } from "./routes/departments.js"; import { createUserRoutes } from "./routes/users.js"; import { createInvoiceRoutes } from "./routes/invoices.js"; +import { createApprovalRoutes } from "./routes/approvals.js"; import { createDocumentsStore, type DocumentsStore } from "./documents.js"; import { errorJson } from "./http.js"; import { cloudFrontOriginAllowed } from "./auth/origin-verify.js"; @@ -52,6 +53,7 @@ export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) { api.route("/", createDepartmentRoutes(handle)); api.route("/", createUserRoutes(handle)); api.route("/", createInvoiceRoutes(handle, deps.documents ?? createDocumentsStore(env))); + api.route("/", createApprovalRoutes(handle)); app.route("/api", api); app.notFound((c) => errorJson(c, 404, "NOT_FOUND", "Not found.")); diff --git a/packages/api/src/approvals.ts b/packages/api/src/approvals.ts new file mode 100644 index 0000000..509b94d --- /dev/null +++ b/packages/api/src/approvals.ts @@ -0,0 +1,74 @@ +import { eq } from "drizzle-orm"; +import type { Db } from "./db/client.js"; +import { activityLog, approvalPolicies, approvalSteps, invoices } from "./db/schema/index.js"; +import { moneyCents, rowsOf } from "./routes/helpers.js"; + +type InvoiceRow = typeof invoices.$inferSelect; +type PolicyRow = typeof approvalPolicies.$inferSelect; +type StepRow = typeof approvalSteps.$inferSelect; + +export async function appendActivity( + handle: Db, + invoiceId: string, + actorUserId: string, + message: string, +) { + await handle.db.insert(activityLog).values({ invoiceId, actorUserId, message }).returning(); +} + +export async function applyMatchingPolicy( + handle: Db, + invoice: InvoiceRow, + actorUserId: string, +): Promise { + await appendActivity(handle, invoice.id, actorUserId, "Invoice created."); + const policies = (await rowsOf(handle, approvalPolicies)) + .filter((policy) => policy.active) + .sort((a, b) => a.priority - b.priority); + const amount = moneyCents(invoice.amount); + const matched = policies.find((policy) => { + if (policy.amountThreshold == null) return true; + return amount >= moneyCents(policy.amountThreshold); + }); + if (!matched) return invoice; + + const skip = matched.skipBelowAmount != null && amount < moneyCents(matched.skipBelowAmount); + await handle.db + .insert(approvalSteps) + .values({ + invoiceId: invoice.id, + policyId: matched.id, + stepOrder: 1, + approverRole: "approver", + status: skip ? "skipped" : "pending", + actedByUserId: skip ? actorUserId : null, + actedAt: skip ? new Date() : null, + }) + .returning(); + if (!skip) { + await appendActivity( + handle, + invoice.id, + actorUserId, + `Approval required by policy ${matched.name}.`, + ); + return invoice; + } + await appendActivity( + handle, + invoice.id, + actorUserId, + `Step skipped because amount is below ${matched.skipBelowAmount}.`, + ); + const [updated] = await handle.db + .update(invoices) + .set({ id: invoice.id, status: "approved", updatedAt: new Date() }) + .where(eq(invoices.id, invoice.id)) + .returning(); + return updated ?? { ...invoice, status: "approved" }; +} + +export async function remainingPending(handle: Db, invoiceId: string): Promise { + const rows = await rowsOf(handle, approvalSteps); + return rows.filter((row) => row.invoiceId === invoiceId && row.status === "pending"); +} diff --git a/packages/api/src/routes/approvals.test.ts b/packages/api/src/routes/approvals.test.ts new file mode 100644 index 0000000..4a50cc3 --- /dev/null +++ b/packages/api/src/routes/approvals.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../app.js"; +import { createMemoryDocumentsStore } from "../documents.js"; +import { loadEnv } from "../env.js"; +import type { ErrorEnvelope } from "../http.js"; +import { createFakeDb, SEED } from "../test/fake-db.js"; + +function expectEnvelope(body: unknown, code: string) { + const envelope = body as ErrorEnvelope; + expect(envelope.error.code).toBe(code); +} + +function envFor(role: "admin" | "approver" | "viewer") { + return loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + DEV_AUTH_SUB: SEED.user.cognitoSub, + DEV_AUTH_EMAIL: SEED.user.email, + DEV_AUTH_NAME: SEED.user.name, + DEV_AUTH_ROLE: role, + }); +} + +const jsonHeaders = { + "content-type": "application/json", + origin: "http://127.0.0.1:3000", +}; + +function app(role: "admin" | "approver" | "viewer" = "admin") { + return createApp(envFor(role), createFakeDb(), { documents: createMemoryDocumentsStore() }); +} + +describe("approval stubs", () => { + it("skips a step when the invoice is below skipBelowAmount", async () => { + const api = app(); + const response = await api.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-SKIP", + amount: "10.00", + dueDate: "2026-10-01", + }), + }); + expect(response.status).toBe(201); + const invoice = (await response.json()) as { id: string; status: string }; + expect(invoice.status).toBe("approved"); + const activity = await api.request(`/api/invoices/${invoice.id}/activity-logs`); + expect(activity.status).toBe(200); + const log = (await activity.json()) as { items: Array<{ message: string }> }; + expect(log.items.some((item) => item.message.includes("below"))).toBe(true); + }); + + it("approves a pending step and writes activity", async () => { + const api = app("approver"); + const response = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ action: "approve" }), + }); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ status: "approved" }); + const activity = await api.request(`/api/invoices/${SEED.invoice.id}/activity-logs`); + const log = (await activity.json()) as { items: Array<{ message: string }> }; + expect(log.items.some((item) => item.message === "Step approved.")).toBe(true); + }); + + it("returns 403 when a viewer acts on a step", async () => { + const api = app("viewer"); + const response = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ action: "approve" }), + }); + expect(response.status).toBe(403); + expectEnvelope(await response.json(), "FORBIDDEN"); + }); + + it("lists the caller inbox and accepts a comment", async () => { + const api = app("admin"); + const inbox = await api.request("/api/inbox"); + expect(inbox.status).toBe(200); + const listed = (await inbox.json()) as { items: Array<{ id: string }> }; + expect(listed.items[0]?.id).toBe(SEED.step.id); + const comment = await api.request(`/api/invoices/${SEED.invoice.id}/comments`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ body: "Looks good." }), + }); + expect(comment.status).toBe(201); + await expect(comment.json()).resolves.toMatchObject({ body: "Looks good." }); + }); + + it("creates an approval policy as admin", async () => { + const api = app("admin"); + const response = await api.request("/api/approval-policies", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + name: "High dollar", + priority: 1, + amountThreshold: "500.00", + skipBelowAmount: "0.00", + }), + }); + expect(response.status).toBe(201); + await expect(response.json()).resolves.toMatchObject({ name: "High dollar", priority: 1 }); + }); +}); diff --git a/packages/api/src/routes/approvals.ts b/packages/api/src/routes/approvals.ts new file mode 100644 index 0000000..c7f55e3 --- /dev/null +++ b/packages/api/src/routes/approvals.ts @@ -0,0 +1,285 @@ +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import type { Db } from "../db/client.js"; +import { + activityLog, + approvalPolicies, + approvalSteps, + invoiceComments, + invoices, +} from "../db/schema/index.js"; +import type { AppBindings } from "../auth/middleware.js"; +import { errorJson } from "../http.js"; +import { + asMoney, + asString, + caller, + firstById, + iso, + isUuid, + parseJsonBody, + requireCan, + rowsOf, +} from "./helpers.js"; +import { appendActivity, remainingPending } from "../approvals.js"; +import type { UserRole } from "../env.js"; + +type PolicyRow = typeof approvalPolicies.$inferSelect; +type StepRow = typeof approvalSteps.$inferSelect; +type InvoiceRow = typeof invoices.$inferSelect; +type CommentRow = typeof invoiceComments.$inferSelect; +type ActivityRow = typeof activityLog.$inferSelect; + +const DECISIONS = ["approve", "reject", "skip"] as const; +type Decision = (typeof DECISIONS)[number]; + +function isDecision(value: string): value is Decision { + return (DECISIONS as readonly string[]).includes(value); +} + +function toPolicy(row: PolicyRow) { + return { + id: row.id, + name: row.name, + priority: row.priority, + amountThreshold: row.amountThreshold, + skipBelowAmount: row.skipBelowAmount, + active: row.active, + createdAt: iso(row.createdAt), + updatedAt: iso(row.updatedAt), + }; +} + +function toStep(row: StepRow) { + return { + id: row.id, + invoiceId: row.invoiceId, + policyId: row.policyId, + stepOrder: row.stepOrder, + approverRole: row.approverRole, + assigneeUserId: row.assigneeUserId, + status: row.status, + actedByUserId: row.actedByUserId, + actedAt: row.actedAt ? iso(row.actedAt) : null, + createdAt: iso(row.createdAt), + }; +} + +function inboxVisible(step: StepRow, user: { id: string; role: UserRole }): boolean { + if (step.status !== "pending") return false; + if (step.assigneeUserId) return step.assigneeUserId === user.id; + return user.role === "admin" || user.role === step.approverRole; +} + +export function createApprovalRoutes(handle: Db) { + const routes = new Hono(); + + routes.get("/approval-policies", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await rowsOf(handle, approvalPolicies); + return c.json({ items: rows.map(toPolicy) }); + }); + + routes.post("/approval-policies", async (c) => { + const denied = requireCan(c, "admin:settings"); + if (denied) return denied; + const body = await parseJsonBody(c); + const name = asString(body.name).trim(); + if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required."); + const [row] = await handle.db + .insert(approvalPolicies) + .values({ + name, + priority: typeof body.priority === "number" ? body.priority : 100, + amountThreshold: asMoney(body.amountThreshold), + skipBelowAmount: asMoney(body.skipBelowAmount), + active: body.active === false ? false : true, + }) + .returning(); + return c.json(toPolicy(row), 201); + }); + + routes.get("/approval-policies/:id", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid policy id."); + const row = await firstById(handle, approvalPolicies, id); + if (!row) return errorJson(c, 404, "NOT_FOUND", "Policy not found."); + return c.json(toPolicy(row)); + }); + + routes.patch("/approval-policies/:id", async (c) => { + const denied = requireCan(c, "admin:settings"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid policy id."); + const existing = await firstById(handle, approvalPolicies, id); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "Policy not found."); + const body = await parseJsonBody(c); + const patch: Partial & { id: string } = { + id, + updatedAt: new Date(), + }; + if (body.name !== undefined) { + const name = asString(body.name).trim(); + if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required."); + patch.name = name; + } + if (typeof body.priority === "number") patch.priority = body.priority; + if (body.amountThreshold !== undefined) patch.amountThreshold = asMoney(body.amountThreshold); + if (body.skipBelowAmount !== undefined) patch.skipBelowAmount = asMoney(body.skipBelowAmount); + if (typeof body.active === "boolean") patch.active = body.active; + const [row] = await handle.db + .update(approvalPolicies) + .set(patch) + .where(eq(approvalPolicies.id, id)) + .returning(); + return c.json(toPolicy(row)); + }); + + routes.get("/inbox", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const user = caller(c); + const steps = await rowsOf(handle, approvalSteps); + const invoiceRows = await rowsOf(handle, invoices); + const items = steps + .filter((step) => inboxVisible(step, user)) + .map((step) => { + const invoice = invoiceRows.find((row) => row.id === step.invoiceId); + return { + ...toStep(step), + invoiceNumber: invoice?.invoiceNumber ?? null, + amount: invoice?.amount ?? null, + vendorId: invoice?.vendorId ?? null, + }; + }); + return c.json({ items }); + }); + + routes.post("/approval-steps/:id/decisions", async (c) => { + const denied = requireCan(c, "approve:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid step id."); + const step = await firstById(handle, approvalSteps, id); + if (!step) return errorJson(c, 404, "NOT_FOUND", "Approval step not found."); + if (step.status !== "pending") { + return errorJson(c, 409, "CONFLICT", "Step is not pending."); + } + const body = await parseJsonBody(c); + const action = asString(body.action); + if (!isDecision(action)) { + return errorJson(c, 400, "VALIDATION_ERROR", "action must be approve, reject, or skip."); + } + const user = caller(c); + const nextStatus = + action === "approve" ? "approved" : action === "reject" ? "rejected" : "skipped"; + const [updated] = await handle.db + .update(approvalSteps) + .set({ + id, + status: nextStatus, + actedByUserId: user.id, + actedAt: new Date(), + }) + .where(eq(approvalSteps.id, id)) + .returning(); + await appendActivity( + handle, + step.invoiceId, + user.id, + action === "approve" + ? "Step approved." + : action === "reject" + ? "Step rejected." + : "Step skipped.", + ); + let invoiceStatus: InvoiceRow["status"] | undefined; + if (action === "reject") invoiceStatus = "rejected"; + else if ((await remainingPending(handle, step.invoiceId)).length === 0) { + invoiceStatus = "approved"; + } + if (invoiceStatus) { + await handle.db + .update(invoices) + .set({ id: step.invoiceId, status: invoiceStatus, updatedAt: new Date() }) + .where(eq(invoices.id, step.invoiceId)) + .returning(); + } + return c.json(toStep(updated)); + }); + + routes.get("/invoices/:id/comments", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const invoice = await firstById(handle, invoices, id); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const rows = (await rowsOf(handle, invoiceComments)).filter( + (row) => row.invoiceId === id, + ); + return c.json({ + items: rows.map((row) => ({ + id: row.id, + invoiceId: row.invoiceId, + authorUserId: row.authorUserId, + body: row.body, + createdAt: iso(row.createdAt), + })), + }); + }); + + routes.post("/invoices/:id/comments", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const invoice = await firstById(handle, invoices, id); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const body = asString((await parseJsonBody(c)).body).trim(); + if (!body) return errorJson(c, 400, "VALIDATION_ERROR", "body is required."); + const user = caller(c); + const [row] = await handle.db + .insert(invoiceComments) + .values({ invoiceId: id, authorUserId: user.id, body }) + .returning(); + await appendActivity(handle, id, user.id, "Comment added."); + return c.json( + { + id: row.id, + invoiceId: row.invoiceId, + authorUserId: row.authorUserId, + body: row.body, + createdAt: iso(row.createdAt), + }, + 201, + ); + }); + + routes.get("/invoices/:id/activity-logs", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const id = c.req.param("id"); + if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id."); + const invoice = await firstById(handle, invoices, id); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const rows = (await rowsOf(handle, activityLog)).filter( + (row) => row.invoiceId === id, + ); + return c.json({ + items: rows.map((row) => ({ + id: row.id, + invoiceId: row.invoiceId, + actorUserId: row.actorUserId, + message: row.message, + createdAt: iso(row.createdAt), + })), + }); + }); + + return routes; +} diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts index 87bf05b..1528ed9 100644 --- a/packages/api/src/routes/invoices.ts +++ b/packages/api/src/routes/invoices.ts @@ -5,6 +5,7 @@ import type { DocumentsStore } from "../documents.js"; import { documents, invoiceLines, invoices, vendors } from "../db/schema/index.js"; import type { AppBindings } from "../auth/middleware.js"; import { errorJson } from "../http.js"; +import { applyMatchingPolicy } from "../approvals.js"; import { asMoney, asString, @@ -246,7 +247,8 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { .values({ invoiceId: row.id, ...line }) .returning(); } - return c.json(toInvoice(row, await linesFor(handle, row.id)), 201); + const latest = await applyMatchingPolicy(handle, row, caller(c).id); + return c.json(toInvoice(latest, await linesFor(handle, row.id)), 201); }); routes.patch("/invoices/:id", async (c) => { @@ -257,7 +259,7 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { const existing = await firstById(handle, invoices, id); if (!existing) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); const body = await parseJsonBody(c); - const patch: Partial = { updatedAt: new Date() }; + const patch: Partial = { id, updatedAt: new Date() }; if (body.vendorId !== undefined) { const vendorId = asString(body.vendorId); if (!isUuid(vendorId)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendorId."); diff --git a/packages/api/src/test/fake-db.ts b/packages/api/src/test/fake-db.ts index b9d10f4..d18380f 100644 --- a/packages/api/src/test/fake-db.ts +++ b/packages/api/src/test/fake-db.ts @@ -68,6 +68,42 @@ export const SEED = { uploadedByUserId: "11111111-1111-4111-8111-111111111111", createdAt: new Date("2026-01-01T00:00:00.000Z"), }, + policy: { + id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + name: "Default approver policy", + priority: 10, + amountThreshold: "0.00", + skipBelowAmount: "25.00", + active: true, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-01T00:00:00.000Z"), + }, + step: { + id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + invoiceId: "88888888-8888-4888-8888-888888888888", + policyId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + stepOrder: 1, + approverRole: "approver" as UserRole, + assigneeUserId: null as string | null, + status: "pending" as const, + actedByUserId: null as string | null, + actedAt: null as Date | null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, + comment: { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + invoiceId: "88888888-8888-4888-8888-888888888888", + authorUserId: "11111111-1111-4111-8111-111111111111", + body: "Seed comment on INV-1001.", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, + activity: { + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + invoiceId: "88888888-8888-4888-8888-888888888888", + actorUserId: "11111111-1111-4111-8111-111111111111", + message: "Invoice created from seed.", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, }; export type Store = { @@ -78,10 +114,10 @@ export type Store = { invoices: Array; invoiceLines: Array; documents: Array; - approvalPolicies: Array>; - approvalSteps: Array>; - invoiceComments: Array>; - activityLog: Array>; + approvalPolicies: Array; + approvalSteps: Array; + invoiceComments: Array; + activityLog: Array; }; export function emptyStore(): Store { @@ -93,10 +129,10 @@ export function emptyStore(): Store { invoices: [{ ...SEED.invoice }], invoiceLines: [{ ...SEED.line }], documents: [{ ...SEED.document }], - approvalPolicies: [], - approvalSteps: [], - invoiceComments: [], - activityLog: [], + approvalPolicies: [{ ...SEED.policy }], + approvalSteps: [{ ...SEED.step }], + invoiceComments: [{ ...SEED.comment }], + activityLog: [{ ...SEED.activity }], }; } @@ -182,7 +218,8 @@ export function createFakeDb(store: Store = emptyStore()): Db { where: vi.fn(() => ({ returning: vi.fn(async () => { const rows = rowsFor(store, table); - const current = rows[0]; + const current = + (typeof patch.id === "string" && rows.find((row) => row.id === patch.id)) || rows[0]; if (!current) return []; Object.assign(current, patch); return [current]; diff --git a/redocly.yaml b/redocly.yaml index 6b39d1c..6b2c306 100644 --- a/redocly.yaml +++ b/redocly.yaml @@ -72,6 +72,7 @@ rules: - callback - refresh - logout + - inbox paths-kebab-case: error no-invalid-schema-examples: error # No schema-properties casing rule: property names mirror the DynamoDB diff --git a/src/api/types.ts b/src/api/types.ts index b4e3f46..e5b36b0 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -117,4 +117,7 @@ export type ApiPaths = { "/api/documents/{id}": { get: { response: Document }; }; + "/api/inbox": { + get: { response: { items: Array<{ id: string; invoiceId: string; status: string }> } }; + }; }; From 335b3f5be2deac2f456e7990bc26f8476765664c Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 12:54:02 -0400 Subject: [PATCH 07/31] test(web): fix SPA client fetch mock types --- src/api/client.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/client.test.ts b/src/api/client.test.ts index 57ad53e..962a388 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -23,7 +23,11 @@ describe("unused SPA API client", () => { const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stage: "local", sha: "x" }))); vi.stubGlobal("fetch", fetchMock); await apiFetch("/api/health", { headers: { Authorization: "Bearer leaked" } }); - const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(fetchMock).toHaveBeenCalledWith( + "/api/health", + expect.objectContaining({ credentials: "include" }), + ); + const init = fetchMock.mock.calls[0]?.[1] as unknown as RequestInit; expect(init.credentials).toBe("include"); const headers = new Headers(init.headers); expect(headers.get("Authorization")).toBeNull(); From c0ad22e3a7f2cdb84e68d38fd538083d345c07c5 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 13:10:44 -0400 Subject: [PATCH 08/31] test(web): cast fetch mock call args for tsc --- src/api/client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/client.test.ts b/src/api/client.test.ts index 962a388..f35b812 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -27,7 +27,7 @@ describe("unused SPA API client", () => { "/api/health", expect.objectContaining({ credentials: "include" }), ); - const init = fetchMock.mock.calls[0]?.[1] as unknown as RequestInit; + const init = (fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1]; expect(init.credentials).toBe("include"); const headers = new Headers(init.headers); expect(headers.get("Authorization")).toBeNull(); From 280014e89de1c35bacfd5d2c1fbe35e45492b1ca Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 13:20:52 -0400 Subject: [PATCH 09/31] fix(api): do not default DEV_AUTH_BYPASS outside local migrate --- packages/api/src/db/migrate.ts | 7 ++----- packages/api/src/env.test.ts | 14 +++++++++++++- packages/api/src/env.ts | 7 +++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/api/src/db/migrate.ts b/packages/api/src/db/migrate.ts index 1506164..26fe890 100644 --- a/packages/api/src/db/migrate.ts +++ b/packages/api/src/db/migrate.ts @@ -2,15 +2,12 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { closeDb, createDb } from "./client.js"; -import { loadEnv } from "../env.js"; +import { loadEnv, withLocalDevAuthBypass } from "../env.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); async function main(): Promise { - const env = loadEnv({ - ...process.env, - DEV_AUTH_BYPASS: process.env.DEV_AUTH_BYPASS ?? "true", - }); + const env = loadEnv(withLocalDevAuthBypass()); if (env.databaseDriver !== "postgres") { throw new Error("db:migrate currently supports DATABASE_DRIVER=postgres only."); diff --git a/packages/api/src/env.test.ts b/packages/api/src/env.test.ts index 8313272..8b0350d 100644 --- a/packages/api/src/env.test.ts +++ b/packages/api/src/env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { loadEnv } from "./env.js"; +import { loadEnv, withLocalDevAuthBypass } from "./env.js"; describe("loadEnv", () => { it("allows DEV_AUTH_BYPASS in development", () => { @@ -49,6 +49,18 @@ describe("loadEnv", () => { ).toThrow(/DEV_AUTH_BYPASS/); }); + it("defaults DEV_AUTH_BYPASS only for local node envs", () => { + expect(withLocalDevAuthBypass({ NODE_ENV: "development" }).DEV_AUTH_BYPASS).toBe("true"); + expect(withLocalDevAuthBypass({ NODE_ENV: "test" }).DEV_AUTH_BYPASS).toBe("true"); + expect(withLocalDevAuthBypass({ NODE_ENV: "production" }).DEV_AUTH_BYPASS).toBeUndefined(); + expect( + withLocalDevAuthBypass({ NODE_ENV: "production", DEV_AUTH_BYPASS: "false" }).DEV_AUTH_BYPASS, + ).toBe("false"); + expect(() => loadEnv(withLocalDevAuthBypass({ NODE_ENV: "production" }))).toThrow( + /COGNITO_ISSUER/, + ); + }); + it("requires Cognito config when bypass is off", () => { expect(() => loadEnv({ diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index c9475f4..12969b3 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -10,6 +10,13 @@ export function isUserRole(value: string): value is UserRole { const LOCAL_NODE_ENVS = new Set(["development", "test"]); +export function withLocalDevAuthBypass(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + if (env.DEV_AUTH_BYPASS !== undefined) return env; + const nodeEnv = env.NODE_ENV ?? "development"; + if (!LOCAL_NODE_ENVS.has(nodeEnv)) return env; + return { ...env, DEV_AUTH_BYPASS: "true" }; +} + export type ApiEnv = { nodeEnv: string; stage: string; From 2d3c3cb0991fc5e11d7f1674441877d79bd7340d Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 13:20:52 -0400 Subject: [PATCH 10/31] fix(api): replace invoice lines in a single transaction --- packages/api/src/routes/invoices.test.ts | 33 ++++++++++++++++++++++-- packages/api/src/routes/invoices.ts | 18 +++++++------ packages/api/src/test/fake-db.ts | 16 ++++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/packages/api/src/routes/invoices.test.ts b/packages/api/src/routes/invoices.test.ts index 23489a4..2292c40 100644 --- a/packages/api/src/routes/invoices.test.ts +++ b/packages/api/src/routes/invoices.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createApp } from "../app.js"; import { createMemoryDocumentsStore } from "../documents.js"; import { loadEnv } from "../env.js"; import type { ErrorEnvelope } from "../http.js"; -import { createFakeDb, SEED } from "../test/fake-db.js"; +import { createFakeDb, emptyStore, SEED } from "../test/fake-db.js"; function expectEnvelope(body: unknown, code: string) { const envelope = body as ErrorEnvelope; @@ -114,6 +114,35 @@ describe("invoice stubs", () => { expect(body.items).toHaveLength(2); }); + it("keeps existing lines when a replace insert fails", async () => { + const store = emptyStore(); + const handle = createFakeDb(store); + const db = handle.db as { insert: ReturnType }; + db.insert.mockImplementation(() => ({ + values: () => ({ + returning: async () => { + throw new Error("insert failed"); + }, + }), + })); + const app = createApp(envFor("admin"), handle, { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}/lines`, { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify({ + items: [ + { description: "Labor", amount: "1000.00" }, + { description: "Parts", amount: "250.00" }, + ], + }), + }); + expect(response.status).toBe(500); + expect(store.invoiceLines).toHaveLength(1); + expect(store.invoiceLines[0]?.id).toBe(SEED.line.id); + }); + it("presigns a document and confirms after upload", async () => { const documents = createMemoryDocumentsStore(); const app = createApp(envFor("admin"), createFakeDb(), { documents }); diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts index 1528ed9..488669c 100644 --- a/packages/api/src/routes/invoices.ts +++ b/packages/api/src/routes/invoices.ts @@ -379,15 +379,17 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { if (!linesSumToAmount(parsedLines, invoice.amount)) { return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount."); } - await handle.db.delete(invoiceLines).where(eq(invoiceLines.invoiceId, id)); const created: LineRow[] = []; - for (const line of parsedLines) { - const [row] = await handle.db - .insert(invoiceLines) - .values({ invoiceId: id, ...line }) - .returning(); - created.push(row); - } + await handle.db.transaction(async (tx) => { + await tx.delete(invoiceLines).where(eq(invoiceLines.invoiceId, id)); + for (const line of parsedLines) { + const [row] = await tx + .insert(invoiceLines) + .values({ invoiceId: id, ...line }) + .returning(); + created.push(row); + } + }); return c.json({ items: created.map(toLine) }); }); diff --git a/packages/api/src/test/fake-db.ts b/packages/api/src/test/fake-db.ts index d18380f..1e5a843 100644 --- a/packages/api/src/test/fake-db.ts +++ b/packages/api/src/test/fake-db.ts @@ -233,6 +233,22 @@ export function createFakeDb(store: Store = emptyStore()): Db { rows.splice(0, rows.length); }), })), + transaction: vi.fn(async (callback: (tx: never) => Promise) => { + const snapshot = structuredClone(store) as Store; + try { + return await callback(db as never); + } catch (error) { + (Object.keys(store) as Array).forEach((key) => { + const rows = store[key] as unknown as Array>; + rows.splice( + 0, + rows.length, + ...(snapshot[key] as unknown as Array>), + ); + }); + throw error; + } + }), }; return { From 7e186e060b6058f556b3643597a0d91fdbc39ed0 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 13:49:03 -0400 Subject: [PATCH 11/31] fix(api): create invoices and lines in one transaction --- packages/api/src/routes/invoices.test.ts | 41 +++++++++++++++++++ packages/api/src/routes/invoices.ts | 50 ++++++++++++++---------- 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/packages/api/src/routes/invoices.test.ts b/packages/api/src/routes/invoices.test.ts index 2292c40..72f12de 100644 --- a/packages/api/src/routes/invoices.test.ts +++ b/packages/api/src/routes/invoices.test.ts @@ -1,3 +1,4 @@ +import { getTableName } from "drizzle-orm"; import { describe, expect, it, vi } from "vitest"; import { createApp } from "../app.js"; import { createMemoryDocumentsStore } from "../documents.js"; @@ -62,6 +63,46 @@ describe("invoice stubs", () => { }); }); + it("does not persist a partial invoice when a line insert fails", async () => { + const store = emptyStore(); + const handle = createFakeDb(store); + const db = handle.db as { insert: ReturnType }; + const originalInsert = db.insert.getMockImplementation() ?? db.insert; + db.insert.mockImplementation((table: unknown) => { + if (getTableName(table as never) === "invoice_lines") { + return { + values: () => ({ + returning: async () => { + throw new Error("insert failed"); + }, + }), + }; + } + return originalInsert(table); + }); + const app = createApp(envFor("admin"), handle, { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request("/api/invoices", { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ + vendorId: SEED.vendor.id, + invoiceNumber: "INV-2003", + amount: "100.00", + dueDate: "2026-10-01", + lines: [ + { description: "Half", amount: "40.00" }, + { description: "Rest", amount: "60.00" }, + ], + }), + }); + expect(response.status).toBe(500); + expect(store.invoices.map((row) => row.invoiceNumber)).toEqual(["INV-1001"]); + expect(store.invoiceLines).toHaveLength(1); + expect(store.invoiceLines[0]?.id).toBe(SEED.line.id); + }); + it("rejects a duplicate active vendor and invoice number", async () => { const app = createApp(envFor("admin"), createFakeDb(), { documents: createMemoryDocumentsStore(), diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts index 488669c..e37d933 100644 --- a/packages/api/src/routes/invoices.ts +++ b/packages/api/src/routes/invoices.ts @@ -215,21 +215,33 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { "An active invoice already uses this vendor and invoice number.", ); } - let row: InvoiceRow; + let latest: InvoiceRow | undefined; + let currentLines: LineRow[] = []; try { - [row] = await handle.db - .insert(invoices) - .values({ - vendorId, - invoiceNumber, - amount, - amountDue: amount, - dueDate, - paymentMethod: method, - memo: asString(body.memo), - status: "pending_approval", - }) - .returning(); + await handle.db.transaction(async (tx) => { + const scoped = { ...handle, db: tx } as typeof handle; + const [row] = await tx + .insert(invoices) + .values({ + vendorId, + invoiceNumber, + amount, + amountDue: amount, + dueDate, + paymentMethod: method, + memo: asString(body.memo), + status: "pending_approval", + }) + .returning(); + for (const line of parsedLines) { + await tx + .insert(invoiceLines) + .values({ invoiceId: row.id, ...line }) + .returning(); + } + latest = await applyMatchingPolicy(scoped, row, caller(c).id); + currentLines = await linesFor(scoped, row.id); + }); } catch (error) { if (isUniqueViolation(error)) { return errorJson( @@ -241,14 +253,10 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { } throw error; } - for (const line of parsedLines) { - await handle.db - .insert(invoiceLines) - .values({ invoiceId: row.id, ...line }) - .returning(); + if (!latest) { + return errorJson(c, 500, "INTERNAL_ERROR", "Invoice create did not complete."); } - const latest = await applyMatchingPolicy(handle, row, caller(c).id); - return c.json(toInvoice(latest, await linesFor(handle, row.id)), 201); + return c.json(toInvoice(latest, currentLines), 201); }); routes.patch("/invoices/:id", async (c) => { From d96fb4fc180d75bd53fa8f59a5c743248f57ee00 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 13:52:58 -0400 Subject: [PATCH 12/31] fix(api): inline GIT_SHA from the image build arg --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 6909726..6021cae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY packages/shared packages/shared COPY packages/api packages/api RUN npm run build:shared && npm run build -w @seahaven-ap/api ARG GIT_SHA=unknown -RUN node -e "require('node:fs').writeFileSync('packages/api/dist/build-info.js', 'export const BUILD_GIT_SHA = ' + JSON.stringify(process.argv[1]) + ';\\n')" "$GIT_SHA" +RUN GIT_SHA="$GIT_SHA" node -e "require('node:fs').writeFileSync('packages/api/dist/build-info.js', 'export const BUILD_GIT_SHA = ' + JSON.stringify(process.env.GIT_SHA || 'unknown') + ';\\n')" FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 RUN addgroup -S app && adduser -S -G app app From 963d48f1404774e64c7067970d075fecaf3b2e17 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 13:57:50 -0400 Subject: [PATCH 13/31] fix(api): stop PATCH from skipping the approval workflow --- packages/api/openapi/paths/invoices-id.yaml | 5 +++-- packages/api/src/routes/invoices.test.ts | 13 +++++++++++++ packages/api/src/routes/invoices.ts | 16 +++++++++------- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/api/openapi/paths/invoices-id.yaml b/packages/api/openapi/paths/invoices-id.yaml index a79fe43..ca7de24 100644 --- a/packages/api/openapi/paths/invoices-id.yaml +++ b/packages/api/openapi/paths/invoices-id.yaml @@ -64,8 +64,9 @@ patch: example: "2026-09-01" status: type: string - enum: [pending_approval, approved, scheduled, paid, rejected, void] - example: approved + enum: [void] + description: The only status PATCH may set. Approval and payment statuses go through decision routes. + example: void paymentMethod: type: string enum: [check, ach] diff --git a/packages/api/src/routes/invoices.test.ts b/packages/api/src/routes/invoices.test.ts index 72f12de..fe84394 100644 --- a/packages/api/src/routes/invoices.test.ts +++ b/packages/api/src/routes/invoices.test.ts @@ -155,6 +155,19 @@ describe("invoice stubs", () => { expect(body.items).toHaveLength(2); }); + it("rejects PATCH that sets approved without an approval decision", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}`, { + method: "PATCH", + headers: jsonHeaders, + body: JSON.stringify({ status: "approved" }), + }); + expect(response.status).toBe(400); + expectEnvelope(await response.json(), "VALIDATION_ERROR"); + }); + it("keeps existing lines when a replace insert fails", async () => { const store = emptyStore(); const handle = createFakeDb(store); diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts index e37d933..ac76206 100644 --- a/packages/api/src/routes/invoices.ts +++ b/packages/api/src/routes/invoices.ts @@ -23,7 +23,6 @@ import { rowsOf, } from "./helpers.js"; -const STATUSES = ["pending_approval", "approved", "scheduled", "paid", "rejected", "void"] as const; const PAYMENT_METHODS = ["check", "ach"] as const; type InvoiceRow = typeof invoices.$inferSelect; @@ -37,10 +36,6 @@ type LineInput = { departmentId: string | null; }; -function isStatus(value: string): value is (typeof STATUSES)[number] { - return (STATUSES as readonly string[]).includes(value); -} - function isPaymentMethod(value: string): value is (typeof PAYMENT_METHODS)[number] { return (PAYMENT_METHODS as readonly string[]).includes(value); } @@ -305,8 +300,15 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { } if (body.status !== undefined) { const status = asString(body.status); - if (!isStatus(status)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid status."); - patch.status = status; + if (status !== "void") { + return errorJson( + c, + 400, + "VALIDATION_ERROR", + "PATCH may only set status to void. Approval and payment statuses go through decision routes.", + ); + } + patch.status = "void"; } if (body.paymentMethod !== undefined) { const method = asString(body.paymentMethod); From 352830ef3e54c067423b3a71694a171ebac1a2f2 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 13:07:43 -0400 Subject: [PATCH 14/31] feat(cd): seahaven-dev SPA and API terraform plus deploy (AP-11) --- .github/workflows/deploy-api.yaml | 228 +++++++++++++++++++ .github/workflows/deploy-web.yaml | 170 ++++++++++++++ README.md | 13 +- placeholder/index.html | 14 ++ scripts/patch-ecs-task-def.py | 59 +++++ scripts/test-terraform-dev-only.py | 69 ++++++ scripts/test-verify-api-health.sh | 79 +++++++ scripts/verify-api-health.sh | 40 ++++ terraform/.gitignore | 11 + terraform/alarms.tf | 34 +++ terraform/artifacts.tf | 101 ++++++++ terraform/aurora.tf | 64 ++++++ terraform/cloudfront.tf | 113 +++++++++ terraform/cognito.tf | 190 ++++++++++++++++ terraform/data.tf | 40 ++++ terraform/documents.tf | 73 ++++++ terraform/ecs.tf | 228 +++++++++++++++++++ terraform/iam_ecs.tf | 107 +++++++++ terraform/iam_github_deploy.tf | 195 ++++++++++++++++ terraform/lambda/cognito-presignup/index.mjs | 17 ++ terraform/locals.tf | 74 ++++++ terraform/logs.tf | 4 + terraform/outputs.tf | 69 ++++++ terraform/providers.tf | 11 + terraform/s3.tf | 96 ++++++++ terraform/secrets.tf | 22 ++ terraform/ssm.tf | 55 +++++ terraform/variables.tf | 55 +++++ terraform/versions.tf | 26 +++ terraform/vpc.tf | 101 ++++++++ 30 files changed, 2357 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/deploy-api.yaml create mode 100644 .github/workflows/deploy-web.yaml create mode 100644 placeholder/index.html create mode 100755 scripts/patch-ecs-task-def.py create mode 100755 scripts/test-terraform-dev-only.py create mode 100755 scripts/test-verify-api-health.sh create mode 100755 scripts/verify-api-health.sh create mode 100644 terraform/.gitignore create mode 100644 terraform/alarms.tf create mode 100644 terraform/artifacts.tf create mode 100644 terraform/aurora.tf create mode 100644 terraform/cloudfront.tf create mode 100644 terraform/cognito.tf create mode 100644 terraform/data.tf create mode 100644 terraform/documents.tf create mode 100644 terraform/ecs.tf create mode 100644 terraform/iam_ecs.tf create mode 100644 terraform/iam_github_deploy.tf create mode 100644 terraform/lambda/cognito-presignup/index.mjs create mode 100644 terraform/locals.tf create mode 100644 terraform/logs.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/providers.tf create mode 100644 terraform/s3.tf create mode 100644 terraform/secrets.tf create mode 100644 terraform/ssm.tf create mode 100644 terraform/variables.tf create mode 100644 terraform/versions.tf create mode 100644 terraform/vpc.tf diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml new file mode 100644 index 0000000..7ae8400 --- /dev/null +++ b/.github/workflows/deploy-api.yaml @@ -0,0 +1,228 @@ +name: Deploy API + +# Fargate image CD. GitHub Actions builds the API image, pushes to ECR, and +# registers a new task definition. Terraform owns the cluster, service, ALB, +# and ignores container_definitions / task_definition. +# +# push to main -> GitHub Environment dev, at github.sha +# workflow_dispatch -> GitHub Environment dev at a chosen ref +# +# Cluster, service, ECR, and task env come from SSM after assuming the +# Environment's DEPLOY_ROLE_ARN. Terraform owns /seahaven-ap/deploy/task-environment; +# this workflow applies that JSON and writes GIT_SHA. Nothing here creates an HCP run. +# Prod is AP-12. + +on: + push: + branches: [main] + paths: + - "packages/api/**" + - "packages/shared/**" + - "package.json" + - "package-lock.json" + - "Dockerfile" + - ".dockerignore" + - "scripts/patch-ecs-task-def.py" + - ".github/workflows/deploy-api.yaml" + workflow_dispatch: + inputs: + environment: + description: "Target Environment" + required: true + type: choice + options: [dev] + ref: + description: "Git ref to build and deploy (branch or SHA). Empty means the workflow ref." + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + target: + name: Resolve target + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + environment: ${{ steps.resolve.outputs.environment }} + ref: ${{ steps.resolve.outputs.ref }} + steps: + - id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + GITHUB_REF_NAME_IN: ${{ github.ref }} + GITHUB_SHA_IN: ${{ github.sha }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} + INPUT_REF: ${{ inputs.ref }} + run: | + set -euo pipefail + case "${EVENT_NAME}" in + push) + if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then + echo "push deploys only run from main" >&2 + exit 1 + fi + environment=dev + ref="${GITHUB_SHA_IN}" + ;; + workflow_dispatch) + environment="${INPUT_ENVIRONMENT:-dev}" + if [ "${environment}" != "dev" ]; then + echo "only GitHub Environment dev is allowed" >&2 + exit 1 + fi + ref="${INPUT_REF:-${GITHUB_SHA_IN}}" + ;; + *) + echo "unsupported event ${EVENT_NAME}" >&2 + exit 1 + ;; + esac + { + echo "environment=${environment}" + echo "ref=${ref}" + } >> "${GITHUB_OUTPUT}" + echo "Deploying ${ref} to ${environment}" + + deploy: + name: Deploy API to ${{ needs.target.outputs.environment }} + needs: target + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: ${{ needs.target.outputs.environment }} + concurrency: + group: deploy-api-${{ needs.target.outputs.environment }} + cancel-in-progress: false + permissions: + contents: read + id-token: write + env: + AWS_REGION: us-east-1 + DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.target.outputs.ref }} + persist-credentials: false + + - name: Resolve commit + id: commit + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + echo "sha=${sha}" >> "${GITHUB_OUTPUT}" + echo "Building ${sha}" + + - name: Configure AWS credentials using OIDC + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + role-to-assume: ${{ env.DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + audience: sts.amazonaws.com + + - name: Get deploy parameters + id: deploy + run: | + set -euo pipefail + get_param() { + aws ssm get-parameter --name "$1" --query Parameter.Value --output text + } + CLUSTER=$(get_param /seahaven-ap/deploy/cluster) + SERVICE=$(get_param /seahaven-ap/deploy/service) + FAMILY=$(get_param /seahaven-ap/deploy/task-family) + ECR=$(get_param /seahaven-ap/deploy/ecr-repository) + CONTAINER=$(get_param /seahaven-ap/deploy/container-name) + DIST_ID=$(get_param /seahaven-ap/deploy/distribution-id) + DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text) + { + echo "cluster=${CLUSTER}" + echo "service=${SERVICE}" + echo "family=${FAMILY}" + echo "ecr=${ECR}" + echo "container=${CONTAINER}" + echo "site_url=https://${DOMAIN}" + } >> "${GITHUB_OUTPUT}" + + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7 + + - name: Build image + env: + ECR: ${{ steps.deploy.outputs.ecr }} + GIT_SHA: ${{ steps.commit.outputs.sha }} + ENVIRONMENT: ${{ needs.target.outputs.environment }} + run: | + set -euo pipefail + docker build \ + --platform linux/amd64 \ + --build-arg "GIT_SHA=${GIT_SHA}" \ + -t "${ECR}:${GIT_SHA}" \ + -t "${ECR}:${ENVIRONMENT}" \ + . + + - name: Push image + env: + ECR: ${{ steps.deploy.outputs.ecr }} + GIT_SHA: ${{ steps.commit.outputs.sha }} + ENVIRONMENT: ${{ needs.target.outputs.environment }} + run: | + set -euo pipefail + docker push "${ECR}:${GIT_SHA}" + docker push "${ECR}:${ENVIRONMENT}" + + - name: Register task definition, migrate, and update service + env: + CLUSTER: ${{ steps.deploy.outputs.cluster }} + SERVICE: ${{ steps.deploy.outputs.service }} + FAMILY: ${{ steps.deploy.outputs.family }} + CONTAINER: ${{ steps.deploy.outputs.container }} + IMAGE: ${{ steps.deploy.outputs.ecr }}:${{ steps.commit.outputs.sha }} + GIT_SHA: ${{ steps.commit.outputs.sha }} + run: | + set -euo pipefail + TASK_ENV_JSON="$(aws ssm get-parameter \ + --name /seahaven-ap/deploy/task-environment \ + --with-decryption \ + --query Parameter.Value \ + --output text)" + export TASK_ENV_JSON + aws ecs describe-task-definition \ + --task-definition "${FAMILY}" \ + --query taskDefinition \ + --output json \ + | python3 scripts/patch-ecs-task-def.py > /tmp/task-def.json + REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)" + NET="$(aws ecs describe-services --cluster "${CLUSTER}" --services "${SERVICE}" \ + --query 'services[0].networkConfiguration.awsvpcConfiguration' --output json)" + export NET + SUBNETS="$(python3 -c 'import json,os; print(",".join(json.loads(os.environ["NET"])["subnets"]))')" + SGS="$(python3 -c 'import json,os; print(",".join(json.loads(os.environ["NET"])["securityGroups"]))')" + TASK_ARN="$(aws ecs run-task \ + --cluster "${CLUSTER}" \ + --task-definition "${FAMILY}:${REV}" \ + --launch-type FARGATE \ + --network-configuration "awsvpcConfiguration={subnets=[${SUBNETS}],securityGroups=[${SGS}],assignPublicIp=ENABLED}" \ + --overrides "{\"containerOverrides\":[{\"name\":\"${CONTAINER}\",\"command\":[\"node\",\"packages/api/dist/db/migrate.js\"]}]}" \ + --query 'tasks[0].taskArn' --output text)" + aws ecs wait tasks-stopped --cluster "${CLUSTER}" --tasks "${TASK_ARN}" + EXIT="$(aws ecs describe-tasks --cluster "${CLUSTER}" --tasks "${TASK_ARN}" \ + --query 'tasks[0].containers[0].exitCode' --output text)" + if [ "${EXIT}" != "0" ]; then + echo "migrate task ${TASK_ARN} exited ${EXIT}" >&2 + exit 1 + fi + aws ecs update-service \ + --cluster "${CLUSTER}" \ + --service "${SERVICE}" \ + --task-definition "${FAMILY}:${REV}" \ + --force-new-deployment \ + >/dev/null + aws ecs wait services-stable --cluster "${CLUSTER}" --services "${SERVICE}" + + - name: Verify API health + env: + SITE_URL: ${{ steps.deploy.outputs.site_url }} + EXPECTED_SHA: ${{ steps.commit.outputs.sha }} + run: bash scripts/verify-api-health.sh diff --git a/.github/workflows/deploy-web.yaml b/.github/workflows/deploy-web.yaml new file mode 100644 index 0000000..36cca87 --- /dev/null +++ b/.github/workflows/deploy-web.yaml @@ -0,0 +1,170 @@ +name: Deploy Web + +# SPA CD. GitHub Actions syncs the committed placeholder to the S3 origin +# bucket root, then invalidates CloudFront. Terraform owns the bucket and the +# distribution and never touches content. Do not run a SPA production build. +# +# push to main -> GitHub Environment dev, at github.sha +# workflow_dispatch -> GitHub Environment dev at a chosen ref +# +# Nothing here creates an HCP run. Prod is AP-12. + +on: + push: + branches: [main] + paths-ignore: + - "terraform/**" + - "packages/api/**" + - "packages/shared/**" + - "docs/**" + - "**/*.md" + - "Dockerfile" + - ".dockerignore" + - "scripts/verify-api-health.sh" + - "scripts/test-verify-api-health.sh" + - "scripts/test-terraform-dev-only.py" + - "scripts/patch-ecs-task-def.py" + - ".github/workflows/deploy-api.yaml" + - ".github/workflows/ci.yaml" + workflow_dispatch: + inputs: + environment: + description: "Target Environment" + required: true + type: choice + options: [dev] + ref: + description: "Git ref to deploy (branch or SHA). Empty means the workflow ref." + required: false + type: string + default: "" + +permissions: + contents: read + +jobs: + target: + name: Resolve target + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + environment: ${{ steps.resolve.outputs.environment }} + ref: ${{ steps.resolve.outputs.ref }} + steps: + - id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + GITHUB_REF_NAME_IN: ${{ github.ref }} + GITHUB_SHA_IN: ${{ github.sha }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} + INPUT_REF: ${{ inputs.ref }} + run: | + set -euo pipefail + case "${EVENT_NAME}" in + push) + if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then + echo "push deploys only run from main" >&2 + exit 1 + fi + environment=dev + ref="${GITHUB_SHA_IN}" + ;; + workflow_dispatch) + environment="${INPUT_ENVIRONMENT:-dev}" + if [ "${environment}" != "dev" ]; then + echo "only GitHub Environment dev is allowed" >&2 + exit 1 + fi + ref="${INPUT_REF:-${GITHUB_SHA_IN}}" + ;; + *) + echo "unsupported event ${EVENT_NAME}" >&2 + exit 1 + ;; + esac + { + echo "environment=${environment}" + echo "ref=${ref}" + } >> "${GITHUB_OUTPUT}" + echo "Deploying ${ref} to ${environment}" + + deploy: + name: Deploy SPA to ${{ needs.target.outputs.environment }} + needs: target + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: ${{ needs.target.outputs.environment }} + concurrency: + group: deploy-web-${{ needs.target.outputs.environment }} + cancel-in-progress: false + permissions: + contents: read + id-token: write + env: + AWS_REGION: us-east-1 + DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.target.outputs.ref }} + persist-credentials: false + + - name: Resolve commit + id: commit + run: | + set -euo pipefail + sha="$(git rev-parse HEAD)" + echo "sha=${sha}" >> "${GITHUB_OUTPUT}" + echo "Deploying ${sha}" + test -f placeholder/index.html + + - name: Configure AWS credentials using OIDC + uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 + with: + role-to-assume: ${{ env.DEPLOY_ROLE_ARN }} + aws-region: us-east-1 + audience: sts.amazonaws.com + + - name: Get deploy parameters + id: deploy + run: | + set -euo pipefail + BUCKET=$(aws ssm get-parameter --name /seahaven-ap/deploy/bucket --query Parameter.Value --output text) + DIST_ID=$(aws ssm get-parameter --name /seahaven-ap/deploy/distribution-id --query Parameter.Value --output text) + DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text) + { + echo "bucket=${BUCKET}" + echo "distribution_id=${DIST_ID}" + echo "site_url=https://${DOMAIN}" + } >> "${GITHUB_OUTPUT}" + + - name: Sync placeholder/ to the bucket root + env: + SITE_BUCKET: ${{ steps.deploy.outputs.bucket }} + run: | + set -euo pipefail + aws s3 sync placeholder/ "s3://${SITE_BUCKET}/" \ + --exclude "index.html" \ + --cache-control "public,max-age=31536000,immutable" + aws s3 cp placeholder/index.html "s3://${SITE_BUCKET}/index.html" \ + --cache-control "no-cache,no-store,must-revalidate" \ + --content-type "text/html" + aws s3 sync placeholder/ "s3://${SITE_BUCKET}/" \ + --delete \ + --exclude "index.html" \ + --cache-control "public,max-age=31536000,immutable" + aws s3api head-object --bucket "${SITE_BUCKET}" --key index.html + + - name: Invalidate CloudFront + env: + DISTRIBUTION_ID: ${{ steps.deploy.outputs.distribution_id }} + run: | + set -euo pipefail + invalidation_id="$(aws cloudfront create-invalidation \ + --distribution-id "${DISTRIBUTION_ID}" \ + --paths "/*" \ + --query Invalidation.Id --output text)" + echo "Invalidation ${invalidation_id} created; waiting" + aws cloudfront wait invalidation-completed \ + --distribution-id "${DISTRIBUTION_ID}" \ + --id "${invalidation_id}" diff --git a/README.md b/README.md index 480e36c..dbd2e94 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sea Haven AP -Internal accounts payable automation for Sea Haven Industries. Local API is `http://127.0.0.1:8787`. CloudFront on seahaven-dev is the first hosted origin. +Internal accounts payable automation for Sea Haven Industries. Local API is `http://127.0.0.1:8787`. Hosted origin on seahaven-dev is the CloudFront default domain after HCP apply; GitHub Environment `dev` deploys the placeholder and API image. ## Workspace layout @@ -54,9 +54,20 @@ npm run lint:api npm run docs:preview # builds HTML via redocly build-docs and opens it ``` +## Hosted seahaven-dev + +HCP Terraform workspace `seahaven-ap-dev` (project `seahaven-dev`) uses working directory `terraform` and a `terraform/**` VCS trigger on `main`. GitHub Environment `dev` holds `DEPLOY_ROLE_ARN` for `githubdeploy-seahaven-ap`. + +- `.github/workflows/deploy-web.yaml` syncs `placeholder/` to the web bucket. It does not run `vite build`. +- `.github/workflows/deploy-api.yaml` builds the API image with `GIT_SHA`, registers the task definition from `/seahaven-ap/deploy/task-environment`, migrates, and checks `GET /api/health`. + +Terraform `environment` is `dev` only. Prod hostname and GitHub Environment `prod` are AP-12. + ## Verify ```bash npm run verify npm run test:e2e +python3 scripts/test-terraform-dev-only.py +bash scripts/test-verify-api-health.sh ``` diff --git a/placeholder/index.html b/placeholder/index.html new file mode 100644 index 0000000..ae4b8e4 --- /dev/null +++ b/placeholder/index.html @@ -0,0 +1,14 @@ + + + + + + Sea Haven AP + + +
+

Sea Haven AP

+

Accounts payable origin for seahaven-dev. The live SPA is not published on this distribution yet.

+
+ + diff --git a/scripts/patch-ecs-task-def.py b/scripts/patch-ecs-task-def.py new file mode 100755 index 0000000..a8f8391 --- /dev/null +++ b/scripts/patch-ecs-task-def.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Apply Terraform-owned task env onto an ECS task definition JSON. + +Reads describe-task-definition JSON on stdin. Writes register-task-definition +input on stdout. IMAGE, GIT_SHA, CONTAINER, and TASK_ENV_JSON must be set. +TASK_ENV_JSON is the SecureString at /seahaven-ap/deploy/task-environment. +GIT_SHA is owned by GitHub and always overwrites the map. +""" + +from __future__ import annotations + +import json +import os +import sys + + +def patch_task_definition(td: dict, *, image: str, sha: str, container_name: str, env_map: dict) -> dict: + if not isinstance(env_map, dict) or not env_map: + raise SystemExit("TASK_ENV_JSON must be a non-empty JSON object") + owned = {str(key): str(value) for key, value in env_map.items()} + owned.pop("GIT_SHA", None) + owned["GIT_SHA"] = sha + + matched = False + for container in td.get("containerDefinitions") or []: + if container.get("name") != container_name: + continue + matched = True + container["image"] = image + container["environment"] = [{"name": key, "value": value} for key, value in owned.items()] + container["stopTimeout"] = 60 + container.pop("command", None) + if not matched: + raise SystemExit(f"container {container_name!r} not found in task definition") + return td + + +def main() -> None: + image = os.environ["IMAGE"] + sha = os.environ["GIT_SHA"] + name = os.environ["CONTAINER"] + env_map = json.loads(os.environ["TASK_ENV_JSON"]) + td = json.load(sys.stdin) + for key in ( + "taskDefinitionArn", + "revision", + "status", + "requiresAttributes", + "compatibilities", + "registeredAt", + "registeredBy", + "deregisteredAt", + ): + td.pop(key, None) + json.dump(patch_task_definition(td, image=image, sha=sha, container_name=name, env_map=env_map), sys.stdout) + + +if __name__ == "__main__": + main() diff --git a/scripts/test-terraform-dev-only.py b/scripts/test-terraform-dev-only.py new file mode 100755 index 0000000..e7ab362 --- /dev/null +++ b/scripts/test-terraform-dev-only.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Guard seahaven-dev-only Terraform and deploy workflows (AP-9/10/11).""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def test_no_hcp_iam_and_no_prod(): + tf_dir = ROOT / "terraform" + assert not (tf_dir / "hcp_iam.tf").exists() + assert not (tf_dir / "acm.tf").exists() + joined = "\n".join(p.read_text() for p in sorted(tf_dir.glob("*.tf"))) + for needle in ( + "environment:prod", + "seahaven-ap-prod", + "seahaven-prod", + "ap.seahaven.com", + "011934824531", + "afterhours", + "hcptf-bootstrap", + 'contains(["dev", "prod"]', + ): + assert needle not in joined, needle + variables = (tf_dir / "variables.tf").read_text() + assert 'var.environment == "dev"' in variables + locals_tf = (tf_dir / "locals.tf").read_text() + assert 'vpc_cidr' in locals_tf and "10.63.0.0/16" in locals_tf + assert 'hcp_workspace' in locals_tf and "seahaven-ap-dev" in locals_tf + ecs = (tf_dir / "ecs.tf").read_text() + assert "ignore_changes = [container_definitions]" in ecs + assert "ignore_changes = [task_definition, desired_count]" in ecs + assert 'path = "/api/health"' in ecs + assert "public.ecr.aws/docker/library/node:24-alpine" in ecs + cloudfront = (tf_dir / "cloudfront.tf").read_text() + assert "cloudfront_default_certificate = true" in cloudfront + assert "aliases" not in cloudfront + github = (tf_dir / "iam_github_deploy.tf").read_text() + assert "environment:dev" in github + assert "environment:prod" not in github + assert "refs/tags/" not in github + assert "deploy-web.yaml@refs/heads/" in github + assert "deploy-api.yaml@refs/heads/" in github + + +def test_deploy_workflows_are_dev_only(): + for name in ("deploy-web.yaml", "deploy-api.yaml"): + text = (ROOT / ".github" / "workflows" / name).read_text() + assert "release:" not in text + assert "options: [dev]" in text + assert "options: [dev, prod]" not in text + assert "environment:prod" not in text + assert "cancel-in-progress: false" in text + assert "environment: ${{ needs.target.outputs.environment }}" in text + web = (ROOT / ".github" / "workflows" / "deploy-web.yaml").read_text() + assert "vite build" not in web + assert "placeholder/" in web + assert "npm run build" not in web + api = (ROOT / ".github" / "workflows" / "deploy-api.yaml").read_text() + assert "/seahaven-ap/deploy/" in api + assert "GIT_SHA" in api + assert "verify-api-health.sh" in api + assert "packages/api/dist/db/migrate.js" in api + + +if __name__ == "__main__": + test_no_hcp_iam_and_no_prod() + test_deploy_workflows_are_dev_only() + print("PASS: seahaven-dev terraform and deploy workflow guards") diff --git a/scripts/test-verify-api-health.sh b/scripts/test-verify-api-health.sh new file mode 100755 index 0000000..092e457 --- /dev/null +++ b/scripts/test-verify-api-health.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Stubbed curl tests for scripts/verify-api-health.sh. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +VERIFY="${ROOT}/scripts/verify-api-health.sh" +SHA="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +failures=0 + +assert_exit() { + local name="$1" expected="$2" got="$3" log="$4" + if [[ "${got}" != "${expected}" ]]; then + echo "FAIL: ${name}: expected exit ${expected}, got ${got}" >&2 + sed -n '1,80p' "${log}" >&2 + failures=$((failures + 1)) + else + echo "PASS: ${name}" + fi +} + +run_with_curl() { + local name="$1" expected="$2" curl_body="$3" + local dir + dir="$(mktemp -d)" + cat > "${dir}/curl" << CURL +#!/usr/bin/env bash +set -euo pipefail +output="" +write_out="" +args=("\$@") +i=0 +while [[ \$i -lt \${#args[@]} ]]; do + arg="\${args[\$i]}" + case "\${arg}" in + -o) i=\$((i + 1)); output="\${args[\$i]}" ;; + -w) i=\$((i + 1)); write_out="\${args[\$i]}" ;; + esac + i=\$((i + 1)) +done +${curl_body} +CURL + chmod +x "${dir}/curl" + export PATH="${dir}:${PATH}" + export SITE_URL="https://d111111abcdef8.cloudfront.net" + export EXPECTED_SHA="${SHA}" + export BUDGET=2 + export INTERVAL=0 + local log="${dir}/log.txt" + set +e + bash "${VERIFY}" > "${log}" 2>&1 + local code=$? + set -e + assert_exit "${name}" "${expected}" "${code}" "${log}" + rm -rf "${dir}" +} + +run_with_curl "matching-sha" 0 ' +[[ -n "${output}" ]] && printf "{\"stage\":\"dev\",\"sha\":\"'"${SHA}"'\"}\n" > "${output}" +[[ -n "${write_out}" ]] && printf "200" +exit 0 +' + +run_with_curl "wrong-sha" 1 ' +[[ -n "${output}" ]] && printf "{\"stage\":\"dev\",\"sha\":\"unknown\"}\n" > "${output}" +[[ -n "${write_out}" ]] && printf "200" +exit 0 +' + +run_with_curl "health-503" 1 ' +[[ -n "${output}" ]] && printf "{\"message\":\"nope\"}\n" > "${output}" +[[ -n "${write_out}" ]] && printf "503" +exit 0 +' + +if [[ "${failures}" -ne 0 ]]; then + echo "FAIL: ${failures} verify-api-health cases failed" >&2 + exit 1 +fi +echo "PASS: API health verify checks" diff --git a/scripts/verify-api-health.sh b/scripts/verify-api-health.sh new file mode 100755 index 0000000..8f8f633 --- /dev/null +++ b/scripts/verify-api-health.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Verify the API origin through CloudFront /api/health. +set -euo pipefail + +SITE_URL="${SITE_URL:-}" +EXPECTED_SHA="${EXPECTED_SHA:-}" +BUDGET="${BUDGET:-20}" +INTERVAL="${INTERVAL:-5}" + +if [[ -z "${SITE_URL}" || -z "${EXPECTED_SHA}" ]]; then + echo "Usage: SITE_URL EXPECTED_SHA must be set." >&2 + exit 2 +fi + +SITE_URL="${SITE_URL%/}" +last_code="unreachable" +last_sha="unreachable" + +attempt=0 +while [[ "${attempt}" -lt "${BUDGET}" ]]; do + attempt=$((attempt + 1)) + tmp="$(mktemp)" + last_code="$(curl -sS --max-time 30 -o "${tmp}" -w '%{http_code}' "${SITE_URL}/api/health" || printf '000')" + last_sha="$(python3 -c 'import json,sys +try: + print(json.load(open(sys.argv[1], encoding="utf-8")).get("sha") or "") +except Exception: + print("") +' "${tmp}")" + rm -f "${tmp}" + echo "poll ${attempt}/${BUDGET}: http=${last_code} sha=${last_sha}" + if [[ "${last_code}" == "200" && "${last_sha}" == "${EXPECTED_SHA}" ]]; then + echo "PASS: GET /api/health is 200 with sha ${EXPECTED_SHA}" + exit 0 + fi + sleep "${INTERVAL}" +done + +echo "FAIL: GET /api/health did not converge to sha ${EXPECTED_SHA} (last http=${last_code} sha=${last_sha})." >&2 +exit 1 diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..de3ad37 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,11 @@ +.terraform/ +*.tfstate +*.tfstate.* +crash.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +*.tfvars +*.tfvars.json +build/ diff --git a/terraform/alarms.tf b/terraform/alarms.tf new file mode 100644 index 0000000..6542005 --- /dev/null +++ b/terraform/alarms.tf @@ -0,0 +1,34 @@ +resource "aws_cloudwatch_metric_alarm" "alb_5xx" { + alarm_name = "${local.project}-alb-5xx" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 1 + metric_name = "HTTPCode_Target_5XX_Count" + namespace = "AWS/ApplicationELB" + period = 60 + statistic = "Sum" + threshold = 0 + treat_missing_data = "notBreaching" + alarm_description = "ALB target 5xx for seahaven-ap." + + dimensions = { + LoadBalancer = aws_lb.api.arn_suffix + } +} + +resource "aws_cloudwatch_metric_alarm" "ecs_cpu" { + alarm_name = "${local.project}-ecs-cpu" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/ECS" + period = 300 + statistic = "Average" + threshold = 80 + treat_missing_data = "notBreaching" + alarm_description = "seahaven-ap ECS CPU above 80 percent." + + dimensions = { + ClusterName = aws_ecs_cluster.api.name + ServiceName = aws_ecs_service.api.name + } +} diff --git a/terraform/artifacts.tf b/terraform/artifacts.tf new file mode 100644 index 0000000..dccd6d6 --- /dev/null +++ b/terraform/artifacts.tf @@ -0,0 +1,101 @@ +resource "aws_s3_bucket" "artifacts" { + bucket = local.artifacts_bucket_name + + tags = { + Purpose = "Cognito pre-signup packages for seahaven-ap" + } +} + +resource "aws_s3_bucket_public_access_block" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + + rule { + id = "expire-noncurrent-packages" + status = "Enabled" + + filter {} + + noncurrent_version_expiration { + noncurrent_days = 180 + } + } + + rule { + id = "abort-incomplete-multipart" + status = "Enabled" + + filter {} + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } + + depends_on = [aws_s3_bucket_versioning.artifacts] +} + +data "aws_iam_policy_document" "artifacts" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + resources = [ + aws_s3_bucket.artifacts.arn, + "${aws_s3_bucket.artifacts.arn}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +resource "aws_s3_bucket_policy" "artifacts" { + bucket = aws_s3_bucket.artifacts.id + policy = data.aws_iam_policy_document.artifacts.json + + depends_on = [aws_s3_bucket_public_access_block.artifacts] +} diff --git a/terraform/aurora.tf b/terraform/aurora.tf new file mode 100644 index 0000000..589f363 --- /dev/null +++ b/terraform/aurora.tf @@ -0,0 +1,64 @@ +resource "aws_security_group" "aurora" { + name = "${local.project}-aurora" + description = "Aurora for seahaven-ap" + vpc_id = local.vpc_id + + ingress { + description = "Postgres from Fargate" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.api.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_db_subnet_group" "api" { + name = local.project + subnet_ids = local.private_subnet_ids + + tags = { + Name = "${local.project}-db" + } +} + +resource "aws_rds_cluster" "api" { + cluster_identifier = local.project + engine = "aurora-postgresql" + engine_mode = "provisioned" + engine_version = "16.6" + database_name = "seahaven_ap" + master_username = "seahaven" + master_password = random_password.db.result + db_subnet_group_name = aws_db_subnet_group.api.name + vpc_security_group_ids = [aws_security_group.aurora.id] + storage_encrypted = true + backup_retention_period = 1 + skip_final_snapshot = true + apply_immediately = true + copy_tags_to_snapshot = true + enable_http_endpoint = false + + serverlessv2_scaling_configuration { + min_capacity = 0.5 + max_capacity = 1 + } + + tags = { + Name = local.project + } +} + +resource "aws_rds_cluster_instance" "api" { + identifier = "${local.project}-1" + cluster_identifier = aws_rds_cluster.api.id + instance_class = "db.serverless" + engine = aws_rds_cluster.api.engine + engine_version = aws_rds_cluster.api.engine_version +} diff --git a/terraform/cloudfront.tf b/terraform/cloudfront.tf new file mode 100644 index 0000000..a7b3b81 --- /dev/null +++ b/terraform/cloudfront.tf @@ -0,0 +1,113 @@ +resource "random_password" "origin_verify" { + length = 32 + special = false +} + +resource "aws_cloudfront_origin_access_control" "web" { + name = "${local.project}-${var.environment}-oac" + description = "OAC for ${local.web_bucket_name}" + origin_access_control_origin_type = "s3" + signing_behavior = "always" + signing_protocol = "sigv4" +} + +resource "aws_cloudfront_function" "spa_rewrite" { + name = "${local.project}-${var.environment}-spa-rewrite" + runtime = "cloudfront-js-1.0" + comment = "SPA routing: rewrite extensionless paths to /index.html" + publish = true + code = local.spa_rewrite_code + + lifecycle { + ignore_changes = [publish] + } +} + +resource "aws_cloudfront_function" "spa_security_headers" { + name = "${local.project}-${var.environment}-spa-security-headers" + runtime = "cloudfront-js-1.0" + comment = "SPA CSP and Permissions-Policy" + publish = true + code = local.spa_security_headers_code + + lifecycle { + ignore_changes = [publish] + } +} + +resource "aws_cloudfront_distribution" "web" { + enabled = true + is_ipv6_enabled = true + http_version = "http2and3" + comment = "${local.project} ${var.environment} SPA" + default_root_object = "index.html" + price_class = "PriceClass_100" + web_acl_id = data.aws_ssm_parameter.app_web_acl_arn.value + + origin { + origin_id = local.s3_origin_id + domain_name = aws_s3_bucket.web.bucket_regional_domain_name + origin_access_control_id = aws_cloudfront_origin_access_control.web.id + } + + origin { + origin_id = local.api_origin_id + domain_name = aws_lb.api.dns_name + custom_header { + name = "X-Origin-Verify" + value = random_password.origin_verify.result + } + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "http-only" + origin_ssl_protocols = ["TLSv1.2"] + } + } + + ordered_cache_behavior { + path_pattern = "/api/*" + target_origin_id = local.api_origin_id + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = local.cache_policy_caching_disabled + origin_request_policy_id = local.origin_request_all_viewer_except_host + response_headers_policy_id = local.response_headers_security_headers + } + + default_cache_behavior { + target_origin_id = local.s3_origin_id + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = local.cache_policy_caching_optimized + response_headers_policy_id = local.response_headers_security_headers + + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.spa_rewrite.arn + } + + function_association { + event_type = "viewer-response" + function_arn = aws_cloudfront_function.spa_security_headers.arn + } + } + + restrictions { + geo_restriction { + restriction_type = "none" + } + } + + viewer_certificate { + cloudfront_default_certificate = true + } + + lifecycle { + prevent_destroy = true + } +} diff --git a/terraform/cognito.tf b/terraform/cognito.tf new file mode 100644 index 0000000..f7a4532 --- /dev/null +++ b/terraform/cognito.tf @@ -0,0 +1,190 @@ +locals { + cognito_prefix_domain = "${local.project}-${var.environment}" + google_oidc = jsondecode(data.aws_secretsmanager_secret_version.google_oidc.secret_string) + google_oidc_client_id = local.google_oidc.client_id + google_oidc_client_secret = sensitive(local.google_oidc.client_secret) + + app_origin = "https://${aws_cloudfront_distribution.web.domain_name}" + + portal_callback_urls = [ + "${local.app_origin}/api/auth/callback", + "http://127.0.0.1:8787/api/auth/callback", + ] + portal_logout_urls = [ + local.app_origin, + "http://127.0.0.1:3000/", + ] + + cognito_pool_id = aws_cognito_user_pool.portal.id + cognito_issuer = "https://cognito-idp.${var.aws_region}.amazonaws.com/${aws_cognito_user_pool.portal.id}" + cognito_client_id = aws_cognito_user_pool_client.portal.id + cognito_hosted_domain = "${aws_cognito_user_pool_domain.prefix.domain}.auth.${var.aws_region}.amazoncognito.com" +} + +data "aws_secretsmanager_secret_version" "google_oidc" { + secret_id = aws_secretsmanager_secret.google_oidc.id +} + +data "archive_file" "cognito_presignup" { + type = "zip" + source_file = "${path.module}/lambda/cognito-presignup/index.mjs" + output_path = "${path.module}/build/packages/cognito-presignup.zip" +} + +resource "aws_s3_object" "cognito_presignup" { + bucket = aws_s3_bucket.artifacts.id + key = "functions/cognito-presignup.zip" + content_base64 = filebase64(data.archive_file.cognito_presignup.output_path) + source_hash = data.archive_file.cognito_presignup.output_base64sha256 +} + +resource "aws_cloudwatch_log_group" "cognito_presignup" { + name = "/aws/lambda/${local.project}-cognito-presignup" + retention_in_days = 14 +} + +data "aws_iam_policy_document" "lambda_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "cognito_presignup" { + name = "${local.project}-cognito-presignup" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.lambda_assume.json + permissions_boundary = data.aws_iam_policy.ecs_task_boundary.arn +} + +resource "aws_iam_role_policy_attachment" "cognito_presignup_basic" { + role = aws_iam_role.cognito_presignup.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_lambda_function" "cognito_presignup" { + function_name = "${local.project}-cognito-presignup" + role = aws_iam_role.cognito_presignup.arn + handler = "index.handler" + runtime = "nodejs24.x" + architectures = ["arm64"] + memory_size = 128 + timeout = 5 + + s3_bucket = aws_s3_bucket.artifacts.id + s3_key = aws_s3_object.cognito_presignup.key + source_code_hash = data.archive_file.cognito_presignup.output_base64sha256 + + depends_on = [ + aws_cloudwatch_log_group.cognito_presignup, + aws_iam_role_policy_attachment.cognito_presignup_basic, + ] +} + +resource "aws_cognito_user_pool" "portal" { + name = local.project + + username_attributes = ["email"] + auto_verified_attributes = ["email"] + mfa_configuration = "OFF" + + admin_create_user_config { + allow_admin_create_user_only = true + } + + password_policy { + minimum_length = 32 + require_lowercase = true + require_numbers = true + require_symbols = true + require_uppercase = true + temporary_password_validity_days = 1 + } + + account_recovery_setting { + recovery_mechanism { + name = "verified_email" + priority = 1 + } + } + + lambda_config { + pre_sign_up = aws_lambda_function.cognito_presignup.arn + } + + tags = { + Project = local.project + } +} + +resource "aws_lambda_permission" "cognito_presignup" { + statement_id = "AllowCognitoInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.cognito_presignup.function_name + principal = "cognito-idp.amazonaws.com" + source_arn = aws_cognito_user_pool.portal.arn + source_account = local.account_id +} + +resource "aws_cognito_identity_provider" "google" { + user_pool_id = aws_cognito_user_pool.portal.id + provider_name = "Google" + provider_type = "Google" + + provider_details = { + client_id = local.google_oidc_client_id + client_secret = local.google_oidc_client_secret + authorize_scopes = "openid email profile" + attributes_url = "https://people.googleapis.com/v1/people/me?personFields=" + attributes_url_add_attributes = "true" + authorize_url = "https://accounts.google.com/o/oauth2/v2/auth" + oidc_issuer = "https://accounts.google.com" + token_url = "https://www.googleapis.com/oauth2/v4/token" + token_request_method = "POST" + } + + attribute_mapping = { + email = "email" + name = "name" + username = "sub" + } +} + +resource "aws_cognito_user_pool_client" "portal" { + name = local.project + user_pool_id = aws_cognito_user_pool.portal.id + + generate_secret = false + allowed_oauth_flows_user_pool_client = true + allowed_oauth_flows = ["code"] + allowed_oauth_scopes = ["openid", "email", "profile"] + supported_identity_providers = ["Google"] + explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_AUTH"] + enable_token_revocation = true + prevent_user_existence_errors = "ENABLED" + + callback_urls = local.portal_callback_urls + logout_urls = local.portal_logout_urls + + access_token_validity = 1 + id_token_validity = 1 + refresh_token_validity = 8 + + token_validity_units { + access_token = "hours" + id_token = "hours" + refresh_token = "hours" + } + + depends_on = [aws_cognito_identity_provider.google] +} + +resource "aws_cognito_user_pool_domain" "prefix" { + domain = local.cognito_prefix_domain + user_pool_id = aws_cognito_user_pool.portal.id +} diff --git a/terraform/data.tf b/terraform/data.tf new file mode 100644 index 0000000..3176f1d --- /dev/null +++ b/terraform/data.tf @@ -0,0 +1,40 @@ +data "aws_caller_identity" "current" {} + +check "correct_account" { + assert { + condition = data.aws_caller_identity.current.account_id == local.account_id + error_message = "This configuration targets account ${local.account_id} (${var.environment}), but the credentials resolve to ${data.aws_caller_identity.current.account_id}." + } +} + +data "aws_ssm_parameter" "app_web_acl_arn" { + name = "/seahaven/waf/app-web-acl-arn" +} + +data "aws_iam_policy" "ecs_task_boundary" { + name = "seahaven-ap-ecs-task-boundary" +} + +data "aws_iam_policy" "github_deploy_boundary" { + name = "seahaven-ap-githubdeploy-boundary" +} + +check "existing_vpc_pair" { + assert { + condition = ( + (var.existing_vpc_id == "") == (length(var.existing_public_subnet_ids) == 0) && + (var.existing_vpc_id == "") == (length(var.existing_private_subnet_ids) == 0) + ) + error_message = "existing_vpc_id, existing_public_subnet_ids, and existing_private_subnet_ids must all be set or all be empty." + } +} + +check "existing_subnets_in_vpc" { + assert { + condition = alltrue(concat( + [for subnet in data.aws_subnet.existing_public : subnet.vpc_id == var.existing_vpc_id], + [for subnet in data.aws_subnet.existing_private : subnet.vpc_id == var.existing_vpc_id], + )) + error_message = "Every existing subnet ID must belong to existing_vpc_id." + } +} diff --git a/terraform/documents.tf b/terraform/documents.tf new file mode 100644 index 0000000..4bd43cd --- /dev/null +++ b/terraform/documents.tf @@ -0,0 +1,73 @@ +resource "aws_s3_bucket" "documents" { + bucket = local.documents_bucket_name + + tags = { + Purpose = "seahaven-ap-invoice-documents" + } +} + +resource "aws_s3_bucket_public_access_block" "documents" { + bucket = aws_s3_bucket.documents.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "documents" { + bucket = aws_s3_bucket.documents.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "documents" { + bucket = aws_s3_bucket.documents.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "documents" { + bucket = aws_s3_bucket.documents.id + + versioning_configuration { + status = "Enabled" + } +} + +data "aws_iam_policy_document" "documents" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + resources = [ + aws_s3_bucket.documents.arn, + "${aws_s3_bucket.documents.arn}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } +} + +resource "aws_s3_bucket_policy" "documents" { + bucket = aws_s3_bucket.documents.id + policy = data.aws_iam_policy_document.documents.json + + depends_on = [aws_s3_bucket_public_access_block.documents] +} diff --git a/terraform/ecs.tf b/terraform/ecs.tf new file mode 100644 index 0000000..620920e --- /dev/null +++ b/terraform/ecs.tf @@ -0,0 +1,228 @@ +resource "aws_ecr_repository" "api" { + name = local.project + image_tag_mutability = "MUTABLE" + force_delete = true + + image_scanning_configuration { + scan_on_push = true + } + + encryption_configuration { + encryption_type = "AES256" + } +} + +resource "aws_ecr_lifecycle_policy" "api" { + repository = aws_ecr_repository.api.name + + policy = jsonencode({ + rules = [ + { + rulePriority = 1 + description = "Keep the last 20 images" + selection = { + tagStatus = "any" + countType = "imageCountMoreThan" + countNumber = 20 + } + action = { + type = "expire" + } + } + ] + }) +} + +resource "aws_security_group" "alb" { + name = "${local.project}-alb" + description = "ALB for seahaven-ap (CloudFront origin only)" + vpc_id = local.vpc_id + + ingress { + description = "HTTP from CloudFront origin-facing prefix list" + from_port = 80 + to_port = 80 + protocol = "tcp" + prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront_origin.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_security_group" "api" { + name = "${local.project}-api" + description = "Fargate tasks for seahaven-ap" + vpc_id = local.vpc_id + + ingress { + description = "From ALB" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + security_groups = [aws_security_group.alb.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_lb" "api" { + name = local.project + load_balancer_type = "application" + idle_timeout = 120 + security_groups = [aws_security_group.alb.id] + subnets = local.public_subnet_ids + drop_invalid_header_fields = true +} + +resource "aws_lb_target_group" "api" { + name = "${local.project}-api" + port = 8080 + protocol = "HTTP" + vpc_id = local.vpc_id + target_type = "ip" + + health_check { + enabled = true + path = "/api/health" + matcher = "200" + interval = 30 + timeout = 5 + healthy_threshold = 2 + unhealthy_threshold = 3 + } +} + +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.api.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.api.arn + } +} + +resource "aws_ecs_cluster" "api" { + name = local.project + + setting { + name = "containerInsights" + value = "disabled" + } +} + +locals { + api_container_name = "api" + bootstrap_command = [ + "node", + "-e", + "require('http').createServer((q,s)=>{const p=(q.url||'').split('?')[0];const ok=q.method==='GET'&&p==='/api/health';const b=Buffer.from(ok?JSON.stringify({stage:'bootstrap',sha:'bootstrap'}):'');s.writeHead(ok?200:503,ok?{'content-type':'application/json','content-length':b.length}:{});s.end(b)}).listen(8080)", + ] + + database_url = "postgresql://seahaven:${urlencode(random_password.db.result)}@${aws_rds_cluster.api.endpoint}:5432/seahaven_ap" + + api_environment_map = { + NODE_ENV = "production" + STAGE = var.environment + API_PORT = "8080" + DATABASE_DRIVER = "postgres" + DATABASE_URL = local.database_url + AWS_REGION = var.aws_region + COGNITO_ISSUER = local.cognito_issuer + COGNITO_AUDIENCE = local.cognito_client_id + COGNITO_DOMAIN = local.cognito_hosted_domain + APP_ORIGIN = local.app_origin + ORIGIN_VERIFY_SECRET = random_password.origin_verify.result + DOCUMENTS_BUCKET = aws_s3_bucket.documents.id + } + + api_environment = concat( + [for name, value in local.api_environment_map : { name = name, value = value }], + [{ name = "GIT_SHA", value = "bootstrap" }], + ) +} + +resource "aws_ecs_task_definition" "api" { + family = local.project + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = "512" + memory = "1024" + execution_role_arn = aws_iam_role.ecs_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + runtime_platform { + operating_system_family = "LINUX" + cpu_architecture = "X86_64" + } + + container_definitions = jsonencode([ + { + name = local.api_container_name + image = "public.ecr.aws/docker/library/node:24-alpine" + essential = true + command = local.bootstrap_command + portMappings = [ + { + containerPort = 8080 + protocol = "tcp" + } + ] + environment = local.api_environment + stopTimeout = 60 + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.api.name + "awslogs-region" = var.aws_region + "awslogs-stream-prefix" = "ecs" + } + } + } + ]) + + lifecycle { + ignore_changes = [container_definitions] + } +} + +resource "aws_ecs_service" "api" { + name = local.project + cluster = aws_ecs_cluster.api.id + task_definition = aws_ecs_task_definition.api.arn + desired_count = 1 + launch_type = "FARGATE" + + network_configuration { + subnets = local.public_subnet_ids + security_groups = [aws_security_group.api.id] + assign_public_ip = true + } + + load_balancer { + target_group_arn = aws_lb_target_group.api.arn + container_name = local.api_container_name + container_port = 8080 + } + + health_check_grace_period_seconds = 60 + deployment_minimum_healthy_percent = 0 + deployment_maximum_percent = 200 + + lifecycle { + ignore_changes = [task_definition, desired_count] + } + + depends_on = [aws_lb_listener.http] +} diff --git a/terraform/iam_ecs.tf b/terraform/iam_ecs.tf new file mode 100644 index 0000000..f1ff969 --- /dev/null +++ b/terraform/iam_ecs.tf @@ -0,0 +1,107 @@ +data "aws_iam_policy_document" "ecs_assume" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} + +data "aws_iam_policy_document" "ecs_task" { + statement { + sid = "ReadProjectParameters" + effect = "Allow" + actions = ["ssm:GetParameter", "ssm:GetParameters"] + resources = ["arn:aws:ssm:${var.aws_region}:${local.account_id}:parameter${local.ssm_prefix}/*"] + } + + statement { + sid = "ReadProjectSecrets" + effect = "Allow" + actions = ["secretsmanager:GetSecretValue"] + resources = ["arn:aws:secretsmanager:${var.aws_region}:${local.account_id}:secret:seahaven-ap/*"] + } + + statement { + sid = "EcrAuth" + effect = "Allow" + actions = ["ecr:GetAuthorizationToken"] + resources = ["*"] + } + + statement { + sid = "EcrPull" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer", + ] + resources = [aws_ecr_repository.api.arn] + } + + statement { + sid = "TaskLogs" + effect = "Allow" + actions = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + "logs:CreateLogGroup", + ] + resources = [ + aws_cloudwatch_log_group.api.arn, + "${aws_cloudwatch_log_group.api.arn}:*", + ] + } + + statement { + sid = "DocumentsBucket" + effect = "Allow" + actions = [ + "s3:ListBucket", + "s3:GetBucketLocation", + ] + resources = [aws_s3_bucket.documents.arn] + } + + statement { + sid = "DocumentsObjects" + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts", + ] + resources = ["${aws_s3_bucket.documents.arn}/*"] + } +} + +resource "aws_iam_role" "ecs_execution" { + name = "${local.project}-ecs-exec" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.ecs_assume.json + permissions_boundary = data.aws_iam_policy.ecs_task_boundary.arn +} + +resource "aws_iam_role_policy_attachment" "ecs_execution" { + role = aws_iam_role.ecs_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role" "ecs_task" { + name = "${local.project}-ecs-task" + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.ecs_assume.json + permissions_boundary = data.aws_iam_policy.ecs_task_boundary.arn +} + +resource "aws_iam_role_policy" "ecs_task" { + name = "api-runtime" + role = aws_iam_role.ecs_task.id + policy = data.aws_iam_policy_document.ecs_task.json +} diff --git a/terraform/iam_github_deploy.tf b/terraform/iam_github_deploy.tf new file mode 100644 index 0000000..2c7295d --- /dev/null +++ b/terraform/iam_github_deploy.tf @@ -0,0 +1,195 @@ +data "aws_iam_policy_document" "github_deploy_assume" { + statement { + sid = "GithubDeployOidc" + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = [local.github_oidc_provider_arn] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:aud" + values = ["sts.amazonaws.com"] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:sub" + values = ["repo:Sea-Haven-Industries@183236204/seahaven-ap@1330218238:environment:dev"] + } + + condition { + test = "StringEquals" + variable = "token.actions.githubusercontent.com:job_workflow_ref" + values = [ + "${var.github_repo}/.github/workflows/deploy-web.yaml@refs/heads/${var.github_deploy_branch}", + "${var.github_repo}/.github/workflows/deploy-api.yaml@refs/heads/${var.github_deploy_branch}", + ] + } + } +} + +resource "aws_iam_role" "github_deploy" { + name = local.deploy_role + path = "/tf-managed/" + description = "GitHub Actions SPA and API deploy role for ${var.github_repo} Environment dev" + assume_role_policy = data.aws_iam_policy_document.github_deploy_assume.json + permissions_boundary = data.aws_iam_policy.github_deploy_boundary.arn + max_session_duration = 3600 +} + +data "aws_iam_policy_document" "github_deploy" { + statement { + sid = "ListWebBucket" + effect = "Allow" + actions = [ + "s3:GetBucketLocation", + "s3:ListBucket", + ] + resources = [aws_s3_bucket.web.arn] + } + + statement { + sid = "SyncWebBucket" + effect = "Allow" + actions = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + ] + resources = ["${aws_s3_bucket.web.arn}/*"] + } + + statement { + sid = "InvalidateDistribution" + effect = "Allow" + actions = [ + "cloudfront:CreateInvalidation", + "cloudfront:GetInvalidation", + "cloudfront:GetDistribution", + ] + resources = [aws_cloudfront_distribution.web.arn] + } + + statement { + sid = "EcrAuth" + effect = "Allow" + actions = [ + "ecr:GetAuthorizationToken", + ] + resources = ["*"] + } + + statement { + sid = "EcrPush" + effect = "Allow" + actions = [ + "ecr:BatchCheckLayerAvailability", + "ecr:BatchGetImage", + "ecr:CompleteLayerUpload", + "ecr:GetDownloadUrlForLayer", + "ecr:InitiateLayerUpload", + "ecr:PutImage", + "ecr:UploadLayerPart", + "ecr:DescribeRepositories", + "ecr:DescribeImages", + ] + resources = [aws_ecr_repository.api.arn] + } + + statement { + sid = "EcsRegisterTaskDefinition" + effect = "Allow" + actions = [ + "ecs:DescribeTaskDefinition", + "ecs:RegisterTaskDefinition", + ] + resources = ["*"] + + condition { + test = "StringEquals" + variable = "aws:RequestedRegion" + values = [var.aws_region] + } + } + + statement { + sid = "EcsUpdateService" + effect = "Allow" + actions = [ + "ecs:DescribeServices", + "ecs:DescribeTasks", + "ecs:ListTasks", + "ecs:RunTask", + "ecs:StopTask", + "ecs:TagResource", + "ecs:UpdateService", + ] + resources = [ + aws_ecs_cluster.api.arn, + aws_ecs_service.api.id, + "arn:aws:ecs:${var.aws_region}:${local.account_id}:task/${local.project}/*", + "arn:aws:ecs:${var.aws_region}:${local.account_id}:task-definition/${local.project}", + "arn:aws:ecs:${var.aws_region}:${local.account_id}:task-definition/${local.project}:*", + ] + } + + statement { + sid = "PassTaskRoles" + effect = "Allow" + actions = ["iam:PassRole"] + resources = [ + aws_iam_role.ecs_task.arn, + aws_iam_role.ecs_execution.arn, + ] + + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["ecs-tasks.amazonaws.com"] + } + } + + statement { + sid = "DeployParams" + effect = "Allow" + actions = [ + "ssm:GetParameter", + ] + resources = [ + aws_ssm_parameter.deploy_bucket.arn, + aws_ssm_parameter.deploy_distribution_id.arn, + aws_ssm_parameter.deploy_cluster.arn, + aws_ssm_parameter.deploy_service.arn, + aws_ssm_parameter.deploy_task_family.arn, + aws_ssm_parameter.deploy_ecr_repository.arn, + aws_ssm_parameter.deploy_container_name.arn, + aws_ssm_parameter.deploy_task_environment.arn, + ] + } + + statement { + sid = "DecryptTaskEnvironment" + effect = "Allow" + actions = ["kms:Decrypt"] + resources = [ + "arn:aws:kms:${var.aws_region}:${local.account_id}:alias/aws/ssm", + "arn:aws:kms:${var.aws_region}:${local.account_id}:key/*", + ] + + condition { + test = "StringEquals" + variable = "kms:ViaService" + values = ["ssm.${var.aws_region}.amazonaws.com"] + } + } +} + +resource "aws_iam_role_policy" "github_deploy" { + name = "seahaven-ap-spa-api-deploy" + role = aws_iam_role.github_deploy.id + policy = data.aws_iam_policy_document.github_deploy.json +} diff --git a/terraform/lambda/cognito-presignup/index.mjs b/terraform/lambda/cognito-presignup/index.mjs new file mode 100644 index 0000000..cbaf7c1 --- /dev/null +++ b/terraform/lambda/cognito-presignup/index.mjs @@ -0,0 +1,17 @@ +const ALLOWED_DOMAINS = new Set(["seahavenind.com", "seahaven.com"]); + +export async function handler(event) { + const email = String(event?.request?.userAttributes?.email ?? "") + .trim() + .toLowerCase(); + const at = email.lastIndexOf("@"); + const domain = at >= 0 ? email.slice(at + 1) : ""; + + if (!ALLOWED_DOMAINS.has(domain)) { + throw new Error("Email domain is not allowed"); + } + + event.response.autoConfirmUser = true; + event.response.autoVerifyEmail = true; + return event; +} diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 0000000..8c456c6 --- /dev/null +++ b/terraform/locals.tf @@ -0,0 +1,74 @@ +locals { + project = "seahaven-ap" + account_id = "710827005802" + hcp_project = "seahaven-dev" + hcp_workspace = "seahaven-ap-dev" + apply_role = "hcptf-seahaven-ap" + plan_role = "hcptf-seahaven-ap-plan" + deploy_role = "githubdeploy-seahaven-ap" + + web_bucket_name = "seahaven-ap-web-${local.account_id}" + artifacts_bucket_name = "seahaven-ap-artifacts-${local.account_id}" + documents_bucket_name = "seahaven-ap-documents-${local.account_id}" + ssm_prefix = "/seahaven-ap" + + manage_vpc = var.existing_vpc_id == "" + vpc_cidr = "10.63.0.0/16" + public_subnet_cidrs = ["10.63.0.0/24", "10.63.1.0/24"] + private_subnet_cidrs = ["10.63.10.0/24", "10.63.11.0/24"] + + github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com" + + cache_policy_caching_optimized = "658327ea-f89d-4fab-a63d-7e88639e58f6" + cache_policy_caching_disabled = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" + origin_request_all_viewer_except_host = "b689b0a8-53d0-40ab-baf2-68738e2966ac" + response_headers_security_headers = "67f7725c-6f97-4210-82d7-5512b31e9d03" + + s3_origin_id = "S3WebOrigin" + api_origin_id = "ApiOrigin" + + spa_csp = join(" ", [ + "default-src 'self';", + "script-src 'self';", + "style-src 'self' 'unsafe-inline';", + "img-src 'self' data:;", + "font-src 'self';", + "connect-src 'self';", + "object-src 'none';", + "base-uri 'self';", + "form-action 'self';", + "frame-ancestors 'none';", + "upgrade-insecure-requests;", + ]) + + spa_permissions_policy = join(", ", [ + "accelerometer=()", + "camera=()", + "geolocation=()", + "gyroscope=()", + "magnetometer=()", + "microphone=()", + "payment=()", + "usb=()", + ]) + + spa_rewrite_code = join("\n", [ + "function handler(event) {", + " var request = event.request;", + " var uri = request.uri;", + " if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {", + " request.uri = '/index.html';", + " }", + " return request;", + "}", + ]) + + spa_security_headers_code = join("\n", [ + "function handler(event) {", + " var headers = event.response.headers;", + " headers['content-security-policy'] = { value: ${jsonencode(local.spa_csp)} };", + " headers['permissions-policy'] = { value: ${jsonencode(local.spa_permissions_policy)} };", + " return event.response;", + "}", + ]) +} diff --git a/terraform/logs.tf b/terraform/logs.tf new file mode 100644 index 0000000..dd7e917 --- /dev/null +++ b/terraform/logs.tf @@ -0,0 +1,4 @@ +resource "aws_cloudwatch_log_group" "api" { + name = "/ecs/${local.project}" + retention_in_days = 14 +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..bd0e486 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,69 @@ +output "web_bucket_name" { + description = "S3 origin bucket name. deploy-web.yaml syncs placeholder/ to the bucket root." + value = aws_s3_bucket.web.bucket +} + +output "cloudfront_distribution_id" { + description = "CloudFront distribution ID. deploy-web.yaml invalidates /* after each sync." + value = aws_cloudfront_distribution.web.id +} + +output "cloudfront_domain_name" { + description = "CloudFront distribution domain (*.cloudfront.net)." + value = aws_cloudfront_distribution.web.domain_name +} + +output "github_deploy_role_arn" { + description = "OIDC role ARN for deploy-web.yaml and deploy-api.yaml (Environment variable DEPLOY_ROLE_ARN)." + value = aws_iam_role.github_deploy.arn +} + +output "ecs_cluster_name" { + description = "ECS cluster name." + value = aws_ecs_cluster.api.name +} + +output "ecs_service_name" { + description = "ECS service name." + value = aws_ecs_service.api.name +} + +output "alb_dns_name" { + description = "API ALB DNS name. CloudFront /api/* origin." + value = aws_lb.api.dns_name +} + +output "cognito_user_pool_id" { + description = "seahaven-ap Cognito user pool ID." + value = aws_cognito_user_pool.portal.id +} + +output "cognito_user_pool_client_id" { + description = "Public app client ID (authorization code + PKCE)." + value = aws_cognito_user_pool_client.portal.id +} + +output "cognito_prefix_domain" { + description = "Cognito hosted UI prefix domain." + value = "${aws_cognito_user_pool_domain.prefix.domain}.auth.${var.aws_region}.amazoncognito.com" +} + +output "vpc_id" { + description = "VPC the ALB, Fargate tasks, and Aurora run in." + value = local.vpc_id +} + +output "public_subnet_ids" { + description = "Public subnet IDs for the ALB and Fargate tasks." + value = local.public_subnet_ids +} + +output "aurora_cluster_endpoint" { + description = "Aurora writer endpoint." + value = aws_rds_cluster.api.endpoint +} + +output "documents_bucket_name" { + description = "Invoice documents bucket." + value = aws_s3_bucket.documents.bucket +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..4f9d903 --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,11 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = local.project + Environment = var.environment + ManagedBy = "terraform" + } + } +} diff --git a/terraform/s3.tf b/terraform/s3.tf new file mode 100644 index 0000000..6a57cfe --- /dev/null +++ b/terraform/s3.tf @@ -0,0 +1,96 @@ +resource "aws_s3_bucket" "web" { + bucket = local.web_bucket_name + + tags = { + Purpose = "seahaven-ap-spa" + } + + lifecycle { + prevent_destroy = true + } +} + +resource "aws_s3_bucket_public_access_block" "web" { + bucket = aws_s3_bucket.web.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_ownership_controls" "web" { + bucket = aws_s3_bucket.web.id + + rule { + object_ownership = "BucketOwnerEnforced" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "web" { + bucket = aws_s3_bucket.web.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "web" { + bucket = aws_s3_bucket.web.id + + versioning_configuration { + status = "Enabled" + } +} + +data "aws_iam_policy_document" "web" { + statement { + sid = "DenyInsecureTransport" + effect = "Deny" + + principals { + type = "*" + identifiers = ["*"] + } + + actions = ["s3:*"] + resources = [ + aws_s3_bucket.web.arn, + "${aws_s3_bucket.web.arn}/*", + ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } + } + + statement { + sid = "AllowCloudFrontOacRead" + effect = "Allow" + + principals { + type = "Service" + identifiers = ["cloudfront.amazonaws.com"] + } + + actions = ["s3:GetObject"] + resources = ["${aws_s3_bucket.web.arn}/*"] + + condition { + test = "StringEquals" + variable = "AWS:SourceArn" + values = [aws_cloudfront_distribution.web.arn] + } + } +} + +resource "aws_s3_bucket_policy" "web" { + bucket = aws_s3_bucket.web.id + policy = data.aws_iam_policy_document.web.json + + depends_on = [aws_s3_bucket_public_access_block.web] +} diff --git a/terraform/secrets.tf b/terraform/secrets.tf new file mode 100644 index 0000000..d587bca --- /dev/null +++ b/terraform/secrets.tf @@ -0,0 +1,22 @@ +resource "aws_secretsmanager_secret" "google_oidc" { + name = "seahaven-ap/google-oidc" + description = "Google OIDC client credentials for seahaven-ap Cognito. Value is written outside Terraform." +} + +resource "aws_secretsmanager_secret" "database" { + name = "seahaven-ap/database" + description = "Aurora master credentials for seahaven-ap" +} + +resource "aws_secretsmanager_secret_version" "database" { + secret_id = aws_secretsmanager_secret.database.id + secret_string = jsonencode({ + username = "seahaven" + password = random_password.db.result + }) +} + +resource "random_password" "db" { + length = 32 + special = false +} diff --git a/terraform/ssm.tf b/terraform/ssm.tf new file mode 100644 index 0000000..725df5c --- /dev/null +++ b/terraform/ssm.tf @@ -0,0 +1,55 @@ +resource "aws_ssm_parameter" "deploy_bucket" { + name = "${local.ssm_prefix}/deploy/bucket" + type = "String" + value = aws_s3_bucket.web.id + description = "SPA origin bucket; deploy-web syncs placeholder/ to the bucket root" +} + +resource "aws_ssm_parameter" "deploy_distribution_id" { + name = "${local.ssm_prefix}/deploy/distribution-id" + type = "String" + value = aws_cloudfront_distribution.web.id + description = "CloudFront distribution ID; deploy-web invalidates /* after sync" +} + +resource "aws_ssm_parameter" "deploy_cluster" { + name = "${local.ssm_prefix}/deploy/cluster" + type = "String" + value = aws_ecs_cluster.api.name + description = "ECS cluster name for deploy-api.yaml" +} + +resource "aws_ssm_parameter" "deploy_service" { + name = "${local.ssm_prefix}/deploy/service" + type = "String" + value = aws_ecs_service.api.name + description = "ECS service name for deploy-api.yaml" +} + +resource "aws_ssm_parameter" "deploy_task_family" { + name = "${local.ssm_prefix}/deploy/task-family" + type = "String" + value = aws_ecs_task_definition.api.family + description = "ECS task definition family for deploy-api.yaml" +} + +resource "aws_ssm_parameter" "deploy_ecr_repository" { + name = "${local.ssm_prefix}/deploy/ecr-repository" + type = "String" + value = aws_ecr_repository.api.repository_url + description = "ECR repository URL for deploy-api.yaml" +} + +resource "aws_ssm_parameter" "deploy_container_name" { + name = "${local.ssm_prefix}/deploy/container-name" + type = "String" + value = local.api_container_name + description = "Container name in the ECS task definition" +} + +resource "aws_ssm_parameter" "deploy_task_environment" { + name = "${local.ssm_prefix}/deploy/task-environment" + type = "SecureString" + value = jsonencode(local.api_environment_map) + description = "Terraform-owned API task env JSON. deploy-api.yaml applies it on each image deploy, then sets GIT_SHA." +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..b6f3138 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,55 @@ +variable "aws_region" { + description = "Region every resource in this configuration is created in." + type = string + default = "us-east-1" +} + +variable "environment" { + description = "HCP workspace stage. seahaven-dev only; prod is AP-12." + type = string + + validation { + condition = var.environment == "dev" + error_message = "environment must be \"dev\". Prod is AP-12." + } +} + +variable "github_repo" { + description = "GitHub owner/name for the SPA and API deploy OIDC trust." + type = string + default = "Sea-Haven-Industries/seahaven-ap" +} + +variable "github_deploy_branch" { + description = "Git branch pinned in job_workflow_ref for the SPA and API deploy role." + type = string + default = "main" +} + +variable "existing_vpc_id" { + description = "When set, place the ALB, Fargate tasks, and Aurora in this VPC instead of creating one." + type = string + default = "" +} + +variable "existing_public_subnet_ids" { + description = "Public subnet IDs in existing_vpc_id. Required with existing_vpc_id." + type = list(string) + default = [] + + validation { + condition = var.existing_vpc_id == "" || length(var.existing_public_subnet_ids) >= 2 + error_message = "existing_public_subnet_ids must list at least two subnets when existing_vpc_id is set." + } +} + +variable "existing_private_subnet_ids" { + description = "Private subnet IDs in existing_vpc_id for Aurora. Required with existing_vpc_id." + type = list(string) + default = [] + + validation { + condition = var.existing_vpc_id == "" || length(var.existing_private_subnet_ids) >= 2 + error_message = "existing_private_subnet_ids must list at least two subnets when existing_vpc_id is set." + } +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..8754570 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.65" + } + archive = { + source = "hashicorp/archive" + version = "~> 2.8" + } + random = { + source = "hashicorp/random" + version = "~> 3.9" + } + } + + cloud { + organization = "seahaven" + + workspaces { + name = "seahaven-ap-dev" + } + } +} diff --git a/terraform/vpc.tf b/terraform/vpc.tf new file mode 100644 index 0000000..a7bb48e --- /dev/null +++ b/terraform/vpc.tf @@ -0,0 +1,101 @@ +data "aws_availability_zones" "available" { + count = local.manage_vpc ? 1 : 0 + state = "available" +} + +data "aws_vpc" "existing" { + count = var.existing_vpc_id == "" ? 0 : 1 + id = var.existing_vpc_id +} + +data "aws_subnet" "existing_public" { + for_each = toset(var.existing_public_subnet_ids) + id = each.value +} + +data "aws_subnet" "existing_private" { + for_each = toset(var.existing_private_subnet_ids) + id = each.value +} + +data "aws_ec2_managed_prefix_list" "cloudfront_origin" { + name = "com.amazonaws.global.cloudfront.origin-facing" +} + +resource "aws_vpc" "this" { + count = local.manage_vpc ? 1 : 0 + + cidr_block = local.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "${local.project}-vpc" + } +} + +resource "aws_internet_gateway" "this" { + count = local.manage_vpc ? 1 : 0 + + vpc_id = aws_vpc.this[0].id + + tags = { + Name = "${local.project}-igw" + } +} + +resource "aws_subnet" "public" { + count = local.manage_vpc ? length(local.public_subnet_cidrs) : 0 + + vpc_id = aws_vpc.this[0].id + cidr_block = local.public_subnet_cidrs[count.index] + availability_zone = data.aws_availability_zones.available[0].names[count.index] + map_public_ip_on_launch = true + + tags = { + Name = "${local.project}-public-${count.index}" + } +} + +resource "aws_subnet" "private" { + count = local.manage_vpc ? length(local.private_subnet_cidrs) : 0 + + vpc_id = aws_vpc.this[0].id + cidr_block = local.private_subnet_cidrs[count.index] + availability_zone = data.aws_availability_zones.available[0].names[count.index] + + tags = { + Name = "${local.project}-private-${count.index}" + } +} + +resource "aws_route_table" "public" { + count = local.manage_vpc ? 1 : 0 + + vpc_id = aws_vpc.this[0].id + + tags = { + Name = "${local.project}-public" + } +} + +resource "aws_route" "public_default" { + count = local.manage_vpc ? 1 : 0 + + route_table_id = aws_route_table.public[0].id + destination_cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this[0].id +} + +resource "aws_route_table_association" "public" { + count = local.manage_vpc ? length(local.public_subnet_cidrs) : 0 + + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public[0].id +} + +locals { + vpc_id = local.manage_vpc ? aws_vpc.this[0].id : try(data.aws_vpc.existing[0].id, var.existing_vpc_id) + public_subnet_ids = local.manage_vpc ? aws_subnet.public[*].id : var.existing_public_subnet_ids + private_subnet_ids = local.manage_vpc ? aws_subnet.private[*].id : var.existing_private_subnet_ids +} From 3fd8b545fb56e9a385baf3e022cd5ba01eec0141 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 13:10:44 -0400 Subject: [PATCH 15/31] style(cd): format placeholder HTML for Prettier --- placeholder/index.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/placeholder/index.html b/placeholder/index.html index ae4b8e4..6a86066 100644 --- a/placeholder/index.html +++ b/placeholder/index.html @@ -1,4 +1,4 @@ - + @@ -8,7 +8,10 @@

Sea Haven AP

-

Accounts payable origin for seahaven-dev. The live SPA is not published on this distribution yet.

+

+ Accounts payable origin for seahaven-dev. The live SPA is not published on this distribution + yet. +

From fdd891ce5161a1e31e17bd39d1c2f66828921fe3 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 13:21:16 -0400 Subject: [PATCH 16/31] fix(cd): force DEV_AUTH_BYPASS off for the migrate task --- .github/workflows/deploy-api.yaml | 2 +- scripts/test-terraform-dev-only.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml index 7ae8400..dcff899 100644 --- a/.github/workflows/deploy-api.yaml +++ b/.github/workflows/deploy-api.yaml @@ -204,7 +204,7 @@ jobs: --task-definition "${FAMILY}:${REV}" \ --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={subnets=[${SUBNETS}],securityGroups=[${SGS}],assignPublicIp=ENABLED}" \ - --overrides "{\"containerOverrides\":[{\"name\":\"${CONTAINER}\",\"command\":[\"node\",\"packages/api/dist/db/migrate.js\"]}]}" \ + --overrides "{\"containerOverrides\":[{\"name\":\"${CONTAINER}\",\"command\":[\"node\",\"packages/api/dist/db/migrate.js\"],\"environment\":[{\"name\":\"DEV_AUTH_BYPASS\",\"value\":\"false\"}]}]}" \ --query 'tasks[0].taskArn' --output text)" aws ecs wait tasks-stopped --cluster "${CLUSTER}" --tasks "${TASK_ARN}" EXIT="$(aws ecs describe-tasks --cluster "${CLUSTER}" --tasks "${TASK_ARN}" \ diff --git a/scripts/test-terraform-dev-only.py b/scripts/test-terraform-dev-only.py index e7ab362..edf0c59 100755 --- a/scripts/test-terraform-dev-only.py +++ b/scripts/test-terraform-dev-only.py @@ -61,6 +61,8 @@ def test_deploy_workflows_are_dev_only(): assert "GIT_SHA" in api assert "verify-api-health.sh" in api assert "packages/api/dist/db/migrate.js" in api + assert "DEV_AUTH_BYPASS" in api + assert '\\"value\\":\\"false\\"' in api if __name__ == "__main__": From 2240fad7e8b330d5de77653a03f5a431520edb70 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 23 Sep 2026 17:52:25 -0400 Subject: [PATCH 17/31] chore: empty commit to retrigger CI From 2bf8d3a6cca9a5b5cdf34b4a7932a64a6e4a9291 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 23 Sep 2026 17:55:49 -0400 Subject: [PATCH 18/31] chore: empty commit to retrigger CI From 3d221e0b245796b2aa8a94f93aabdfcfafc9a9f0 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 23 Sep 2026 18:37:45 -0400 Subject: [PATCH 19/31] chore: empty commit to retrigger CI From b2043de28f719d4ba9d5e487edc382f3f1df6551 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 23 Sep 2026 18:46:30 -0400 Subject: [PATCH 20/31] chore: empty commit to retrigger CI From 5c5300a2a8cb4ecaeb40a96a1ea6a28f6035f7cb Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Wed, 23 Sep 2026 19:42:29 -0400 Subject: [PATCH 21/31] chore: empty commit to retrigger CI From 70a8fdde3801a9c6ee4fa0f2dca1f1fc43cd47de Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 16:37:06 -0400 Subject: [PATCH 22/31] fix(api): address review feedback --- Dockerfile | 4 +- packages/api/src/app.ts | 5 +- packages/api/src/approvals.ts | 29 ++++++-- packages/api/src/auth/middleware.ts | 9 +-- packages/api/src/auth/upsert-user.test.ts | 83 ++++++++++++++++++----- packages/api/src/auth/upsert-user.ts | 26 ++++--- packages/api/src/db/client.ts | 11 +++ packages/api/src/http.ts | 11 +++ packages/api/src/routes/approvals.test.ts | 52 +++++++++++++- packages/api/src/routes/approvals.ts | 10 ++- packages/api/src/routes/helpers.ts | 16 +++-- packages/api/src/routes/invoices.test.ts | 15 ++++ packages/api/src/routes/invoices.ts | 13 ++-- 13 files changed, 235 insertions(+), 49 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6021cae..01279b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 AS build +FROM --platform=$BUILDPLATFORM node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 AS build WORKDIR /app COPY package.json package-lock.json ./ COPY packages/shared/package.json packages/shared/package.json @@ -10,7 +10,7 @@ RUN npm run build:shared && npm run build -w @seahaven-ap/api ARG GIT_SHA=unknown RUN GIT_SHA="$GIT_SHA" node -e "require('node:fs').writeFileSync('packages/api/dist/build-info.js', 'export const BUILD_GIT_SHA = ' + JSON.stringify(process.env.GIT_SHA || 'unknown') + ';\\n')" -FROM node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 +FROM --platform=linux/amd64 node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 RUN addgroup -S app && adduser -S -G app app WORKDIR /app COPY package.json package-lock.json ./ diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index eb647df..a4a95a7 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -12,7 +12,7 @@ import { createUserRoutes } from "./routes/users.js"; import { createInvoiceRoutes } from "./routes/invoices.js"; import { createApprovalRoutes } from "./routes/approvals.js"; import { createDocumentsStore, type DocumentsStore } from "./documents.js"; -import { errorJson } from "./http.js"; +import { ApiError, errorJson } from "./http.js"; import { cloudFrontOriginAllowed } from "./auth/origin-verify.js"; import { csrfAllowed, isMutating } from "./auth/oauth.js"; import type { CognitoTokenClient } from "./auth/cognito.js"; @@ -58,6 +58,9 @@ export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) { app.notFound((c) => errorJson(c, 404, "NOT_FOUND", "Not found.")); app.onError((error, c) => { + if (error instanceof ApiError) { + return errorJson(c, error.status, error.code, error.message); + } console.error(error); return errorJson(c, 500, "INTERNAL_ERROR", "Internal server error."); }); diff --git a/packages/api/src/approvals.ts b/packages/api/src/approvals.ts index 509b94d..89afeb6 100644 --- a/packages/api/src/approvals.ts +++ b/packages/api/src/approvals.ts @@ -1,5 +1,5 @@ import { eq } from "drizzle-orm"; -import type { Db } from "./db/client.js"; +import type { DbHandle } from "./db/client.js"; import { activityLog, approvalPolicies, approvalSteps, invoices } from "./db/schema/index.js"; import { moneyCents, rowsOf } from "./routes/helpers.js"; @@ -8,7 +8,7 @@ type PolicyRow = typeof approvalPolicies.$inferSelect; type StepRow = typeof approvalSteps.$inferSelect; export async function appendActivity( - handle: Db, + handle: DbHandle, invoiceId: string, actorUserId: string, message: string, @@ -17,7 +17,7 @@ export async function appendActivity( } export async function applyMatchingPolicy( - handle: Db, + handle: DbHandle, invoice: InvoiceRow, actorUserId: string, ): Promise { @@ -68,7 +68,28 @@ export async function applyMatchingPolicy( return updated ?? { ...invoice, status: "approved" }; } -export async function remainingPending(handle: Db, invoiceId: string): Promise { +export async function remainingPending(handle: DbHandle, invoiceId: string): Promise { const rows = await rowsOf(handle, approvalSteps); return rows.filter((row) => row.invoiceId === invoiceId && row.status === "pending"); } + +export async function closePendingSteps( + handle: DbHandle, + invoiceId: string, + actorUserId: string | null, +): Promise { + const pending = await remainingPending(handle, invoiceId); + const actedAt = new Date(); + for (const step of pending) { + await handle.db + .update(approvalSteps) + .set({ + id: step.id, + status: "skipped", + actedByUserId: actorUserId, + actedAt, + }) + .where(eq(approvalSteps.id, step.id)) + .returning(); + } +} diff --git a/packages/api/src/auth/middleware.ts b/packages/api/src/auth/middleware.ts index d6d9426..f5707aa 100644 --- a/packages/api/src/auth/middleware.ts +++ b/packages/api/src/auth/middleware.ts @@ -23,12 +23,13 @@ export type AuthDeps = { verifyToken?: TokenVerifier; }; -function roleFromClaims(claims: Record, fallback: UserRole): UserRole { +function roleFromClaims(claims: Record): UserRole | undefined { const raw = (typeof claims["custom:role"] === "string" && claims["custom:role"]) || (typeof claims.role === "string" && claims.role) || - fallback; - return isUserRole(raw) ? raw : fallback; + undefined; + if (raw === undefined) return undefined; + return isUserRole(raw) ? raw : undefined; } function audienceMatches(payload: JWTPayload, expected: string): boolean { @@ -141,7 +142,7 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db, deps: AuthDeps = { cognitoSub: identity.sub, email: identity.email, name: identity.name, - role: roleFromClaims(payload as Record, "viewer"), + role: roleFromClaims(payload as Record), }); } catch (error) { if (error instanceof IdentityConflictError) { diff --git a/packages/api/src/auth/upsert-user.test.ts b/packages/api/src/auth/upsert-user.test.ts index 8eceaa8..6afb9e9 100644 --- a/packages/api/src/auth/upsert-user.test.ts +++ b/packages/api/src/auth/upsert-user.test.ts @@ -5,7 +5,7 @@ import { IdentityConflictError, upsertUserFromIdentity } from "./upsert-user.js" function createDb(options: { bySub?: Record | null; byEmail?: Record | null; -}): Db { +}): { handle: Db; setSpy: ReturnType } { const findFirst = vi.fn(async (_args: { where: unknown }) => { // drizzle eq objects aren't introspectable here; alternate by call order. if (findFirst.mock.calls.length === 1) { @@ -22,30 +22,35 @@ function createDb(options: { role: "admin" as const, }; + const setSpy = vi.fn((patch: Record) => ({ + where: vi.fn(() => ({ + returning: vi.fn(async () => [{ ...returningRow, ...patch, role: patch.role ?? returningRow.role }]), + })), + })); + return { - driver: "postgres", - pool: { end: vi.fn(async () => undefined) } as never, - db: { - query: { users: { findFirst } }, - update: vi.fn(() => ({ - set: vi.fn(() => ({ - where: vi.fn(() => ({ + handle: { + driver: "postgres", + pool: { end: vi.fn(async () => undefined) } as never, + db: { + query: { users: { findFirst } }, + update: vi.fn(() => ({ + set: setSpy, + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ returning: vi.fn(async () => [returningRow]), })), })), - })), - insert: vi.fn(() => ({ - values: vi.fn(() => ({ - returning: vi.fn(async () => [returningRow]), - })), - })), - } as never, + } as never, + }, + setSpy, }; } describe("upsertUserFromIdentity", () => { it("updates an existing row matched by cognito sub", async () => { - const handle = createDb({ + const { handle } = createDb({ bySub: { id: "11111111-1111-4111-8111-111111111111", cognitoSub: "seed-sub-admin", @@ -66,8 +71,52 @@ describe("upsertUserFromIdentity", () => { expect(handle.db.update).toHaveBeenCalled(); }); + it("preserves the stored role when the token omits a role claim", async () => { + const { handle, setSpy } = createDb({ + bySub: { + id: "11111111-1111-4111-8111-111111111111", + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + role: "admin", + }, + }); + + const user = await upsertUserFromIdentity(handle, { + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + }); + + expect(setSpy).toHaveBeenCalledWith( + expect.not.objectContaining({ role: expect.anything() }), + ); + expect(user.role).toBe("admin"); + }); + + it("writes an explicit role claim over the stored role", async () => { + const { handle, setSpy } = createDb({ + bySub: { + id: "11111111-1111-4111-8111-111111111111", + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + role: "viewer", + }, + }); + + await upsertUserFromIdentity(handle, { + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + role: "approver", + }); + + expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ role: "approver" })); + }); + it("refuses to rebind an email owned by a different cognito sub", async () => { - const handle = createDb({ + const { handle } = createDb({ bySub: null, byEmail: { id: "11111111-1111-4111-8111-111111111111", diff --git a/packages/api/src/auth/upsert-user.ts b/packages/api/src/auth/upsert-user.ts index d0373a5..fdf3201 100644 --- a/packages/api/src/auth/upsert-user.ts +++ b/packages/api/src/auth/upsert-user.ts @@ -7,7 +7,8 @@ export type AuthIdentity = { cognitoSub: string; email: string; name: string; - role: UserRole; + /** Present only when the token carries an explicit role claim. */ + role?: UserRole; }; export type AuthUser = { @@ -46,6 +47,7 @@ function toAuthUser(row: { /** * Upsert by Cognito subject only. Never rebind an existing email to a new * subject — that would allow account takeover if email claims collide. + * Missing role claims leave the stored role unchanged. */ export async function upsertUserFromIdentity( handle: Db, @@ -56,14 +58,22 @@ export async function upsertUserFromIdentity( }); if (bySub) { + const patch: { + email: string; + name: string; + role?: UserRole; + updatedAt: Date; + } = { + email: identity.email, + name: identity.name, + updatedAt: new Date(), + }; + if (identity.role !== undefined) { + patch.role = identity.role; + } const [updated] = await handle.db .update(users) - .set({ - email: identity.email, - name: identity.name, - role: identity.role, - updatedAt: new Date(), - }) + .set(patch) .where(eq(users.id, bySub.id)) .returning(); return toAuthUser(updated); @@ -83,7 +93,7 @@ export async function upsertUserFromIdentity( cognitoSub: identity.cognitoSub, email: identity.email, name: identity.name, - role: identity.role, + role: identity.role ?? "viewer", }) .returning(); diff --git a/packages/api/src/db/client.ts b/packages/api/src/db/client.ts index cddde03..12195e9 100644 --- a/packages/api/src/db/client.ts +++ b/packages/api/src/db/client.ts @@ -10,6 +10,17 @@ export type PostgresDb = ReturnType; export type DataApiDb = ReturnType; export type Db = PostgresDb | DataApiDb; +type PostgresTx = Parameters[0]>[0]; +type DataApiTx = Parameters[0]>[0]; + +/** + * Root connection or a transaction-scoped handle. Query helpers accept this so + * callers can pass `{ db: tx }` without casting a transaction to Db. + */ +export type DbHandle = { + db: Db["db"] | PostgresTx | DataApiTx; +}; + function createPostgresDb(env: ApiEnv) { const pool = new pg.Pool({ connectionString: env.databaseUrl }); return { diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 3afbd8c..bee88b8 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -11,6 +11,17 @@ export type ErrorCode = | "INTERNAL_ERROR" | "DATABASE_UNAVAILABLE"; +export class ApiError extends Error { + constructor( + readonly status: ContentfulStatusCode, + readonly code: ErrorCode, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + export const CORRELATION_HEADER = "x-correlation-id"; export type ErrorEnvelope = { diff --git a/packages/api/src/routes/approvals.test.ts b/packages/api/src/routes/approvals.test.ts index 4a50cc3..eafb6d8 100644 --- a/packages/api/src/routes/approvals.test.ts +++ b/packages/api/src/routes/approvals.test.ts @@ -3,7 +3,7 @@ import { createApp } from "../app.js"; import { createMemoryDocumentsStore } from "../documents.js"; import { loadEnv } from "../env.js"; import type { ErrorEnvelope } from "../http.js"; -import { createFakeDb, SEED } from "../test/fake-db.js"; +import { createFakeDb, emptyStore, SEED } from "../test/fake-db.js"; function expectEnvelope(body: unknown, code: string) { const envelope = body as ErrorEnvelope; @@ -77,6 +77,56 @@ describe("approval stubs", () => { expectEnvelope(await response.json(), "FORBIDDEN"); }); + it("returns 403 when the caller is not the assignee for the step", async () => { + const store = emptyStore(); + store.approvalSteps[0] = { + ...SEED.step, + assigneeUserId: "22222222-2222-4222-8222-222222222222", + }; + const api = createApp(envFor("admin"), createFakeDb(store), { + documents: createMemoryDocumentsStore(), + }); + const response = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ action: "approve" }), + }); + expect(response.status).toBe(403); + expectEnvelope(await response.json(), "FORBIDDEN"); + expect(store.approvalSteps[0]?.status).toBe("pending"); + }); + + it("voids an invoice, closes pending steps, and rejects later decisions", async () => { + const store = emptyStore(); + const api = createApp(envFor("admin"), createFakeDb(store), { + documents: createMemoryDocumentsStore(), + }); + const voided = await api.request(`/api/invoices/${SEED.invoice.id}`, { + method: "PATCH", + headers: jsonHeaders, + body: JSON.stringify({ status: "void" }), + }); + expect(voided.status).toBe(200); + await expect(voided.json()).resolves.toMatchObject({ status: "void" }); + expect(store.approvalSteps[0]?.status).toBe("skipped"); + + const inbox = await api.request("/api/inbox"); + expect(inbox.status).toBe(200); + const listed = (await inbox.json()) as { items: Array<{ id: string }> }; + expect(listed.items.some((item) => item.id === SEED.step.id)).toBe(false); + + // Re-open a pending step to prove voided invoices stay sealed even if a step remains. + store.approvalSteps[0] = { ...store.approvalSteps[0]!, status: "pending" }; + const decision = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ action: "approve" }), + }); + expect(decision.status).toBe(409); + expectEnvelope(await decision.json(), "CONFLICT"); + expect(store.invoices[0]?.status).toBe("void"); + }); + it("lists the caller inbox and accepts a comment", async () => { const api = app("admin"); const inbox = await api.request("/api/inbox"); diff --git a/packages/api/src/routes/approvals.ts b/packages/api/src/routes/approvals.ts index c7f55e3..cd48c7d 100644 --- a/packages/api/src/routes/approvals.ts +++ b/packages/api/src/routes/approvals.ts @@ -169,12 +169,20 @@ export function createApprovalRoutes(handle: Db) { if (step.status !== "pending") { return errorJson(c, 409, "CONFLICT", "Step is not pending."); } + const user = caller(c); + if (!inboxVisible(step, user)) { + return errorJson(c, 403, "FORBIDDEN", "You are not an assignee for this approval step."); + } + const invoice = await firstById(handle, invoices, step.invoiceId); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + if (invoice.status === "void") { + return errorJson(c, 409, "CONFLICT", "Cannot act on a voided invoice."); + } const body = await parseJsonBody(c); const action = asString(body.action); if (!isDecision(action)) { return errorJson(c, 400, "VALIDATION_ERROR", "action must be approve, reject, or skip."); } - const user = caller(c); const nextStatus = action === "approve" ? "approved" : action === "reject" ? "rejected" : "skipped"; const [updated] = await handle.db diff --git a/packages/api/src/routes/helpers.ts b/packages/api/src/routes/helpers.ts index d12dfd1..ef75037 100644 --- a/packages/api/src/routes/helpers.ts +++ b/packages/api/src/routes/helpers.ts @@ -1,9 +1,9 @@ import { randomUUID } from "node:crypto"; import type { Context } from "hono"; -import type { Db } from "../db/client.js"; +import type { DbHandle } from "../db/client.js"; import type { UserRole } from "../env.js"; import { can, type RbacAction } from "../auth/rbac.js"; -import { errorJson } from "../http.js"; +import { ApiError, errorJson } from "../http.js"; import type { AppBindings } from "../auth/middleware.js"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -43,8 +43,12 @@ export function newId(): string { return randomUUID(); } -export function parseJsonBody(c: Context): Promise> { - return c.req.json>(); +export async function parseJsonBody(c: Context): Promise> { + try { + return await c.req.json>(); + } catch { + throw new ApiError(400, "VALIDATION_ERROR", "Request body must be valid JSON."); + } } export function asMoney(value: unknown): string | null { @@ -65,12 +69,12 @@ export function isDateOnly(value: string): boolean { return /^\d{4}-\d{2}-\d{2}$/.test(value); } -export async function rowsOf(handle: Db, table: unknown): Promise { +export async function rowsOf(handle: DbHandle, table: unknown): Promise { return (await handle.db.select().from(table as never)) as T[]; } export async function firstById( - handle: Db, + handle: DbHandle, table: unknown, id: string, ): Promise { diff --git a/packages/api/src/routes/invoices.test.ts b/packages/api/src/routes/invoices.test.ts index fe84394..aab0993 100644 --- a/packages/api/src/routes/invoices.test.ts +++ b/packages/api/src/routes/invoices.test.ts @@ -168,6 +168,21 @@ describe("invoice stubs", () => { expectEnvelope(await response.json(), "VALIDATION_ERROR"); }); + it("returns 400 VALIDATION_ERROR for a malformed JSON body", async () => { + const app = createApp(envFor("admin"), createFakeDb(), { + documents: createMemoryDocumentsStore(), + }); + const response = await app.request(`/api/invoices/${SEED.invoice.id}`, { + method: "PATCH", + headers: jsonHeaders, + body: "{not-json", + }); + expect(response.status).toBe(400); + const body = (await response.json()) as ErrorEnvelope; + expect(body.error.code).toBe("VALIDATION_ERROR"); + expect(body.error.message).toBe("Request body must be valid JSON."); + }); + it("keeps existing lines when a replace insert fails", async () => { const store = emptyStore(); const handle = createFakeDb(store); diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts index ac76206..4f54d5d 100644 --- a/packages/api/src/routes/invoices.ts +++ b/packages/api/src/routes/invoices.ts @@ -1,11 +1,11 @@ import { eq } from "drizzle-orm"; import { Hono } from "hono"; -import type { Db } from "../db/client.js"; +import type { Db, DbHandle } from "../db/client.js"; import type { DocumentsStore } from "../documents.js"; import { documents, invoiceLines, invoices, vendors } from "../db/schema/index.js"; import type { AppBindings } from "../auth/middleware.js"; import { errorJson } from "../http.js"; -import { applyMatchingPolicy } from "../approvals.js"; +import { applyMatchingPolicy, closePendingSteps } from "../approvals.js"; import { asMoney, asString, @@ -103,13 +103,13 @@ function linesSumToAmount(lines: Array<{ amount: string }>, amount: string): boo return sum === moneyCents(amount); } -async function linesFor(handle: Db, invoiceId: string): Promise { +async function linesFor(handle: DbHandle, invoiceId: string): Promise { const rows = await rowsOf(handle, invoiceLines); return rows.filter((row) => row.invoiceId === invoiceId); } async function activeDuplicate( - handle: Db, + handle: DbHandle, vendorId: string, invoiceNumber: string, exceptId?: string, @@ -214,7 +214,7 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { let currentLines: LineRow[] = []; try { await handle.db.transaction(async (tx) => { - const scoped = { ...handle, db: tx } as typeof handle; + const scoped: DbHandle = { db: tx }; const [row] = await tx .insert(invoices) .values({ @@ -335,6 +335,9 @@ export function createInvoiceRoutes(handle: Db, store: DocumentsStore) { } let row: InvoiceRow; try { + if (patch.status === "void") { + await closePendingSteps(handle, id, caller(c).id); + } [row] = await handle.db.update(invoices).set(patch).where(eq(invoices.id, id)).returning(); } catch (error) { if (isUniqueViolation(error)) { From 3b0ab4aa838df3677883eb185d3b9bbd733563ba Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 16:37:08 -0400 Subject: [PATCH 23/31] fix(cd): address review feedback --- scripts/test-terraform-dev-only.py | 20 ++++++++++++++++++-- terraform/alarms.tf | 2 ++ terraform/cognito.tf | 6 ++++-- terraform/locals.tf | 3 +++ terraform/secrets.tf | 14 ++++++++++++++ 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/scripts/test-terraform-dev-only.py b/scripts/test-terraform-dev-only.py index edf0c59..3ae5f5c 100755 --- a/scripts/test-terraform-dev-only.py +++ b/scripts/test-terraform-dev-only.py @@ -25,8 +25,8 @@ def test_no_hcp_iam_and_no_prod(): variables = (tf_dir / "variables.tf").read_text() assert 'var.environment == "dev"' in variables locals_tf = (tf_dir / "locals.tf").read_text() - assert 'vpc_cidr' in locals_tf and "10.63.0.0/16" in locals_tf - assert 'hcp_workspace' in locals_tf and "seahaven-ap-dev" in locals_tf + assert "vpc_cidr" in locals_tf and "10.63.0.0/16" in locals_tf + assert "hcp_workspace" in locals_tf and "seahaven-ap-dev" in locals_tf ecs = (tf_dir / "ecs.tf").read_text() assert "ignore_changes = [container_definitions]" in ecs assert "ignore_changes = [task_definition, desired_count]" in ecs @@ -35,6 +35,22 @@ def test_no_hcp_iam_and_no_prod(): cloudfront = (tf_dir / "cloudfront.tf").read_text() assert "cloudfront_default_certificate = true" in cloudfront assert "aliases" not in cloudfront + alarms = (tf_dir / "alarms.tf").read_text() + assert alarms.count("alarm_actions = [local.site_alerts_arn]") == 2 + assert "insufficient_data_actions" not in alarms + assert "ok_actions" not in alarms + locals_tf = (tf_dir / "locals.tf").read_text() + assert ( + 'site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts"' + in locals_tf + ) + cognito = (tf_dir / "cognito.tf").read_text() + assert 'supported_identity_providers = ["COGNITO", "Google"]' in cognito + assert '"ALLOW_USER_SRP_AUTH"' in cognito + assert "aws_secretsmanager_secret_version.google_oidc" in cognito + secrets = (tf_dir / "secrets.tf").read_text() + assert 'resource "aws_secretsmanager_secret_version" "google_oidc"' in secrets + assert "ignore_changes = [secret_string]" in secrets github = (tf_dir / "iam_github_deploy.tf").read_text() assert "environment:dev" in github assert "environment:prod" not in github diff --git a/terraform/alarms.tf b/terraform/alarms.tf index 6542005..1322798 100644 --- a/terraform/alarms.tf +++ b/terraform/alarms.tf @@ -9,6 +9,7 @@ resource "aws_cloudwatch_metric_alarm" "alb_5xx" { threshold = 0 treat_missing_data = "notBreaching" alarm_description = "ALB target 5xx for seahaven-ap." + alarm_actions = [local.site_alerts_arn] dimensions = { LoadBalancer = aws_lb.api.arn_suffix @@ -26,6 +27,7 @@ resource "aws_cloudwatch_metric_alarm" "ecs_cpu" { threshold = 80 treat_missing_data = "notBreaching" alarm_description = "seahaven-ap ECS CPU above 80 percent." + alarm_actions = [local.site_alerts_arn] dimensions = { ClusterName = aws_ecs_cluster.api.name diff --git a/terraform/cognito.tf b/terraform/cognito.tf index f7a4532..50439a5 100644 --- a/terraform/cognito.tf +++ b/terraform/cognito.tf @@ -23,6 +23,8 @@ locals { data "aws_secretsmanager_secret_version" "google_oidc" { secret_id = aws_secretsmanager_secret.google_oidc.id + + depends_on = [aws_secretsmanager_secret_version.google_oidc] } data "archive_file" "cognito_presignup" { @@ -163,8 +165,8 @@ resource "aws_cognito_user_pool_client" "portal" { allowed_oauth_flows_user_pool_client = true allowed_oauth_flows = ["code"] allowed_oauth_scopes = ["openid", "email", "profile"] - supported_identity_providers = ["Google"] - explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_AUTH"] + supported_identity_providers = ["COGNITO", "Google"] + explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH"] enable_token_revocation = true prevent_user_existence_errors = "ENABLED" diff --git a/terraform/locals.tf b/terraform/locals.tf index 8c456c6..07710e1 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -19,6 +19,9 @@ locals { github_oidc_provider_arn = "arn:aws:iam::${local.account_id}:oidc-provider/token.actions.githubusercontent.com" + # Org-baseline topic in this account. Alarm-only; no OK or insufficient-data action. + site_alerts_arn = "arn:aws:sns:${var.aws_region}:${local.account_id}:site-alerts" + cache_policy_caching_optimized = "658327ea-f89d-4fab-a63d-7e88639e58f6" cache_policy_caching_disabled = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" origin_request_all_viewer_except_host = "b689b0a8-53d0-40ab-baf2-68738e2966ac" diff --git a/terraform/secrets.tf b/terraform/secrets.tf index d587bca..3ee9452 100644 --- a/terraform/secrets.tf +++ b/terraform/secrets.tf @@ -3,6 +3,20 @@ resource "aws_secretsmanager_secret" "google_oidc" { description = "Google OIDC client credentials for seahaven-ap Cognito. Value is written outside Terraform." } +# Placeholder so the first apply has an AWSCURRENT version to read. Replace the +# value in Secrets Manager; Terraform will not write this placeholder back. +resource "aws_secretsmanager_secret_version" "google_oidc" { + secret_id = aws_secretsmanager_secret.google_oidc.id + secret_string = jsonencode({ + client_id = "replace-me" + client_secret = "replace-me" + }) + + lifecycle { + ignore_changes = [secret_string] + } +} + resource "aws_secretsmanager_secret" "database" { name = "seahaven-ap/database" description = "Aurora master credentials for seahaven-ap" From b9a384d1c90d3d6d9c95fd44d73c7a8678f93923 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 16:48:03 -0400 Subject: [PATCH 24/31] fix(ci): format upsert-user test --- packages/api/src/auth/upsert-user.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/api/src/auth/upsert-user.test.ts b/packages/api/src/auth/upsert-user.test.ts index 6afb9e9..882a313 100644 --- a/packages/api/src/auth/upsert-user.test.ts +++ b/packages/api/src/auth/upsert-user.test.ts @@ -24,7 +24,9 @@ function createDb(options: { const setSpy = vi.fn((patch: Record) => ({ where: vi.fn(() => ({ - returning: vi.fn(async () => [{ ...returningRow, ...patch, role: patch.role ?? returningRow.role }]), + returning: vi.fn(async () => [ + { ...returningRow, ...patch, role: patch.role ?? returningRow.role }, + ]), })), })); @@ -88,9 +90,7 @@ describe("upsertUserFromIdentity", () => { name: "Dev Admin", }); - expect(setSpy).toHaveBeenCalledWith( - expect.not.objectContaining({ role: expect.anything() }), - ); + expect(setSpy).toHaveBeenCalledWith(expect.not.objectContaining({ role: expect.anything() })); expect(user.role).toBe("admin"); }); From c770c59a408a0a5b0947cdce95226382c0903389 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 17:03:05 -0400 Subject: [PATCH 25/31] fix(ci): run deploy scripts from the trusted workflow commit --- .github/workflows/deploy-api.yaml | 25 +++++++++++++++++++++---- .github/workflows/deploy-web.yaml | 8 +++++++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml index dcff899..6b01871 100644 --- a/.github/workflows/deploy-api.yaml +++ b/.github/workflows/deploy-api.yaml @@ -5,7 +5,9 @@ name: Deploy API # and ignores container_definitions / task_definition. # # push to main -> GitHub Environment dev, at github.sha -# workflow_dispatch -> GitHub Environment dev at a chosen ref +# workflow_dispatch -> GitHub Environment dev. The workflow file must be main. +# inputs.ref is only the image source. Deploy scripts stay +# on github.sha, which is the trusted workflow commit. # # Cluster, service, ECR, and task env come from SSM after assuming the # Environment's DEPLOY_ROLE_ARN. Terraform owns /seahaven-ap/deploy/task-environment; @@ -68,6 +70,10 @@ jobs: ref="${GITHUB_SHA_IN}" ;; workflow_dispatch) + if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then + echo "workflow_dispatch deploys only run from main" >&2 + exit 1 + fi environment="${INPUT_ENVIRONMENT:-dev}" if [ "${environment}" != "dev" ]; then echo "only GitHub Environment dev is allowed" >&2 @@ -102,13 +108,23 @@ jobs: AWS_REGION: us-east-1 DEPLOY_ROLE_ARN: ${{ vars.DEPLOY_ROLE_ARN }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout trusted workflow + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + path: ci + + - name: Checkout image source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.target.outputs.ref }} persist-credentials: false + path: src - name: Resolve commit id: commit + working-directory: src run: | set -euo pipefail sha="$(git rev-parse HEAD)" @@ -153,6 +169,7 @@ jobs: ECR: ${{ steps.deploy.outputs.ecr }} GIT_SHA: ${{ steps.commit.outputs.sha }} ENVIRONMENT: ${{ needs.target.outputs.environment }} + working-directory: src run: | set -euo pipefail docker build \ @@ -192,7 +209,7 @@ jobs: --task-definition "${FAMILY}" \ --query taskDefinition \ --output json \ - | python3 scripts/patch-ecs-task-def.py > /tmp/task-def.json + | python3 "${GITHUB_WORKSPACE}/ci/scripts/patch-ecs-task-def.py" > /tmp/task-def.json REV="$(aws ecs register-task-definition --cli-input-json file:///tmp/task-def.json --query taskDefinition.revision --output text)" NET="$(aws ecs describe-services --cluster "${CLUSTER}" --services "${SERVICE}" \ --query 'services[0].networkConfiguration.awsvpcConfiguration' --output json)" @@ -225,4 +242,4 @@ jobs: env: SITE_URL: ${{ steps.deploy.outputs.site_url }} EXPECTED_SHA: ${{ steps.commit.outputs.sha }} - run: bash scripts/verify-api-health.sh + run: bash "${GITHUB_WORKSPACE}/ci/scripts/verify-api-health.sh" diff --git a/.github/workflows/deploy-web.yaml b/.github/workflows/deploy-web.yaml index 36cca87..3e2ea96 100644 --- a/.github/workflows/deploy-web.yaml +++ b/.github/workflows/deploy-web.yaml @@ -5,7 +5,9 @@ name: Deploy Web # distribution and never touches content. Do not run a SPA production build. # # push to main -> GitHub Environment dev, at github.sha -# workflow_dispatch -> GitHub Environment dev at a chosen ref +# workflow_dispatch -> GitHub Environment dev. The workflow file must be main. +# inputs.ref selects the placeholder tree to publish. +# Job steps are the workflow file, not scripts from that ref. # # Nothing here creates an HCP run. Prod is AP-12. @@ -70,6 +72,10 @@ jobs: ref="${GITHUB_SHA_IN}" ;; workflow_dispatch) + if [ "${GITHUB_REF_NAME_IN}" != "refs/heads/main" ]; then + echo "workflow_dispatch deploys only run from main" >&2 + exit 1 + fi environment="${INPUT_ENVIRONMENT:-dev}" if [ "${environment}" != "dev" ]; then echo "only GitHub Environment dev is allowed" >&2 From 15e80d56c0aee28bd6fac7917e17080cefb3e1be Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Fri, 25 Sep 2026 18:51:30 -0400 Subject: [PATCH 26/31] fix(ci): report migrate run-task failures directly --- .github/workflows/deploy-api.yaml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml index 6b01871..0761dcc 100644 --- a/.github/workflows/deploy-api.yaml +++ b/.github/workflows/deploy-api.yaml @@ -216,13 +216,19 @@ jobs: export NET SUBNETS="$(python3 -c 'import json,os; print(",".join(json.loads(os.environ["NET"])["subnets"]))')" SGS="$(python3 -c 'import json,os; print(",".join(json.loads(os.environ["NET"])["securityGroups"]))')" - TASK_ARN="$(aws ecs run-task \ + RUN_JSON="$(aws ecs run-task \ --cluster "${CLUSTER}" \ --task-definition "${FAMILY}:${REV}" \ --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={subnets=[${SUBNETS}],securityGroups=[${SGS}],assignPublicIp=ENABLED}" \ --overrides "{\"containerOverrides\":[{\"name\":\"${CONTAINER}\",\"command\":[\"node\",\"packages/api/dist/db/migrate.js\"],\"environment\":[{\"name\":\"DEV_AUTH_BYPASS\",\"value\":\"false\"}]}]}" \ - --query 'tasks[0].taskArn' --output text)" + --output json)" + TASK_ARN="$(printf '%s' "${RUN_JSON}" | python3 -c 'import json,sys; data=json.load(sys.stdin); tasks=data.get("tasks") or []; print(tasks[0].get("taskArn") or "" if tasks else "")')" + if [ -z "${TASK_ARN}" ] || [ "${TASK_ARN}" = "None" ]; then + echo "ecs run-task did not start a migrate task" >&2 + printf '%s' "${RUN_JSON}" | python3 -c 'import json,sys; data=json.load(sys.stdin); print(json.dumps(data.get("failures") or [], indent=2))' >&2 + exit 1 + fi aws ecs wait tasks-stopped --cluster "${CLUSTER}" --tasks "${TASK_ARN}" EXIT="$(aws ecs describe-tasks --cluster "${CLUSTER}" --tasks "${TASK_ARN}" \ --query 'tasks[0].containers[0].exitCode' --output text)" From f0a200f6e95f092d007a2779d06b5937561ec0f4 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Sat, 26 Sep 2026 16:23:42 -0400 Subject: [PATCH 27/31] fix(cd): keep SHA-tagged API images for rollback --- scripts/test-terraform-dev-only.py | 2 ++ terraform/ecs.tf | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/test-terraform-dev-only.py b/scripts/test-terraform-dev-only.py index 3ae5f5c..e36d70c 100755 --- a/scripts/test-terraform-dev-only.py +++ b/scripts/test-terraform-dev-only.py @@ -32,6 +32,8 @@ def test_no_hcp_iam_and_no_prod(): assert "ignore_changes = [task_definition, desired_count]" in ecs assert 'path = "/api/health"' in ecs assert "public.ecr.aws/docker/library/node:24-alpine" in ecs + assert 'tagStatus = "untagged"' in ecs + assert 'tagStatus = "any"' not in ecs cloudfront = (tf_dir / "cloudfront.tf").read_text() assert "cloudfront_default_certificate = true" in cloudfront assert "aliases" not in cloudfront diff --git a/terraform/ecs.tf b/terraform/ecs.tf index 620920e..437fd21 100644 --- a/terraform/ecs.tf +++ b/terraform/ecs.tf @@ -19,11 +19,12 @@ resource "aws_ecr_lifecycle_policy" "api" { rules = [ { rulePriority = 1 - description = "Keep the last 20 images" + description = "Expire untagged images. SHA tags stay so registered task revisions can roll back." selection = { - tagStatus = "any" - countType = "imageCountMoreThan" - countNumber = 20 + tagStatus = "untagged" + countType = "sinceImagePushed" + countUnit = "days" + countNumber = 14 } action = { type = "expire" From 9b56475656cdfd4d579c7ce18b24b26a6da29b79 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Sat, 26 Sep 2026 16:29:23 -0400 Subject: [PATCH 28/31] ci: print this repo's OIDC claim format --- .github/workflows/oidc-claims.yaml | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/oidc-claims.yaml diff --git a/.github/workflows/oidc-claims.yaml b/.github/workflows/oidc-claims.yaml new file mode 100644 index 0000000..aaaa1e6 --- /dev/null +++ b/.github/workflows/oidc-claims.yaml @@ -0,0 +1,31 @@ +name: oidc-claims + +on: + pull_request: + branches: [main] + +permissions: + contents: read + id-token: write + +jobs: + claims: + runs-on: ubuntu-latest + steps: + - name: Print selected OIDC claims + run: | + set -euo pipefail + RESP="$(curl -fsS -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sts.amazonaws.com")" + TOKEN="$(printf '%s' "${RESP}" | jq -r .value)" + echo "::add-mask::${TOKEN}" + PAYLOAD="$(printf '%s' "${TOKEN}" | cut -d. -f2)" + unset TOKEN RESP + python3 -c ' + import base64, json, sys + raw = sys.argv[1] + raw += "=" * (-len(raw) % 4) + data = json.loads(base64.urlsafe_b64decode(raw)) + keep = ["sub", "job_workflow_ref", "workflow_ref", "repository", "repository_id", "repository_owner_id"] + print(json.dumps({k: data.get(k) for k in keep}, indent=2)) + ' "${PAYLOAD}" From da959dda93d764e0b6982ec6e13bd56a307a1297 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Sat, 26 Sep 2026 16:39:28 -0400 Subject: [PATCH 29/31] fix(cd): block the Google IdP until real credentials are set --- .github/workflows/oidc-claims.yaml | 31 ------------------------------ README.md | 2 ++ scripts/test-terraform-dev-only.py | 4 ++++ terraform/cognito.tf | 7 +++++++ 4 files changed, 13 insertions(+), 31 deletions(-) delete mode 100644 .github/workflows/oidc-claims.yaml diff --git a/.github/workflows/oidc-claims.yaml b/.github/workflows/oidc-claims.yaml deleted file mode 100644 index aaaa1e6..0000000 --- a/.github/workflows/oidc-claims.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: oidc-claims - -on: - pull_request: - branches: [main] - -permissions: - contents: read - id-token: write - -jobs: - claims: - runs-on: ubuntu-latest - steps: - - name: Print selected OIDC claims - run: | - set -euo pipefail - RESP="$(curl -fsS -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ - "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sts.amazonaws.com")" - TOKEN="$(printf '%s' "${RESP}" | jq -r .value)" - echo "::add-mask::${TOKEN}" - PAYLOAD="$(printf '%s' "${TOKEN}" | cut -d. -f2)" - unset TOKEN RESP - python3 -c ' - import base64, json, sys - raw = sys.argv[1] - raw += "=" * (-len(raw) % 4) - data = json.loads(base64.urlsafe_b64decode(raw)) - keep = ["sub", "job_workflow_ref", "workflow_ref", "repository", "repository_id", "repository_owner_id"] - print(json.dumps({k: data.get(k) for k in keep}, indent=2)) - ' "${PAYLOAD}" diff --git a/README.md b/README.md index dbd2e94..9b8c731 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ npm run docs:preview # builds HTML via redocly build-docs and opens it HCP Terraform workspace `seahaven-ap-dev` (project `seahaven-dev`) uses working directory `terraform` and a `terraform/**` VCS trigger on `main`. GitHub Environment `dev` holds `DEPLOY_ROLE_ARN` for `githubdeploy-seahaven-ap`. +The first apply creates secret `seahaven-ap/google-oidc` with `client_id` and `client_secret` set to `replace-me`. Replace both values in Secrets Manager, then re-run the HCP apply. A `terraform/**` change on `main` starts that apply. The Google IdP is not registered while the placeholder is still current. + - `.github/workflows/deploy-web.yaml` syncs `placeholder/` to the web bucket. It does not run `vite build`. - `.github/workflows/deploy-api.yaml` builds the API image with `GIT_SHA`, registers the task definition from `/seahaven-ap/deploy/task-environment`, migrates, and checks `GET /api/health`. diff --git a/scripts/test-terraform-dev-only.py b/scripts/test-terraform-dev-only.py index e36d70c..0e64cc2 100755 --- a/scripts/test-terraform-dev-only.py +++ b/scripts/test-terraform-dev-only.py @@ -50,6 +50,10 @@ def test_no_hcp_iam_and_no_prod(): assert 'supported_identity_providers = ["COGNITO", "Google"]' in cognito assert '"ALLOW_USER_SRP_AUTH"' in cognito assert "aws_secretsmanager_secret_version.google_oidc" in cognito + assert 'local.google_oidc_client_id != "replace-me"' in cognito + assert 'local.google_oidc_client_secret != "replace-me"' in cognito + readme = (ROOT / "README.md").read_text() + assert "Replace both values in Secrets Manager, then re-run the HCP apply." in readme secrets = (tf_dir / "secrets.tf").read_text() assert 'resource "aws_secretsmanager_secret_version" "google_oidc"' in secrets assert "ignore_changes = [secret_string]" in secrets diff --git a/terraform/cognito.tf b/terraform/cognito.tf index 50439a5..3c6c0c4 100644 --- a/terraform/cognito.tf +++ b/terraform/cognito.tf @@ -138,6 +138,13 @@ resource "aws_cognito_identity_provider" "google" { provider_name = "Google" provider_type = "Google" + lifecycle { + precondition { + condition = local.google_oidc_client_id != "replace-me" && local.google_oidc_client_secret != "replace-me" + error_message = "seahaven-ap/google-oidc still has the placeholder. Replace client_id and client_secret in Secrets Manager, then re-run the HCP apply." + } + } + provider_details = { client_id = local.google_oidc_client_id client_secret = local.google_oidc_client_secret From 0a16df1cfec261a9161bfd7109d196304537b3c4 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Sat, 26 Sep 2026 16:43:38 -0400 Subject: [PATCH 30/31] fix(cd): report the real health status when curl fails --- scripts/test-verify-api-health.sh | 17 ++++++++++++++++- scripts/verify-api-health.sh | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/scripts/test-verify-api-health.sh b/scripts/test-verify-api-health.sh index 092e457..b841ade 100755 --- a/scripts/test-verify-api-health.sh +++ b/scripts/test-verify-api-health.sh @@ -19,7 +19,7 @@ assert_exit() { } run_with_curl() { - local name="$1" expected="$2" curl_body="$3" + local name="$1" expected="$2" curl_body="$3" must="${4:-}" forbid="${5:-}" local dir dir="$(mktemp -d)" cat > "${dir}/curl" << CURL @@ -51,6 +51,16 @@ CURL local code=$? set -e assert_exit "${name}" "${expected}" "${code}" "${log}" + if [[ -n "${must}" ]] && ! grep -F "${must}" "${log}" >/dev/null; then + echo "FAIL: ${name}: log missing ${must}" >&2 + sed -n '1,80p' "${log}" >&2 + failures=$((failures + 1)) + fi + if [[ -n "${forbid}" ]] && grep -F "${forbid}" "${log}" >/dev/null; then + echo "FAIL: ${name}: log contains ${forbid}" >&2 + sed -n '1,80p' "${log}" >&2 + failures=$((failures + 1)) + fi rm -rf "${dir}" } @@ -72,6 +82,11 @@ run_with_curl "health-503" 1 ' exit 0 ' +run_with_curl "curl-failure" 1 ' +[[ -n "${write_out}" ]] && printf "000" +exit 1 +' 'http=000 sha=' 'http=000000' + if [[ "${failures}" -ne 0 ]]; then echo "FAIL: ${failures} verify-api-health cases failed" >&2 exit 1 diff --git a/scripts/verify-api-health.sh b/scripts/verify-api-health.sh index 8f8f633..de09b4f 100755 --- a/scripts/verify-api-health.sh +++ b/scripts/verify-api-health.sh @@ -20,7 +20,7 @@ attempt=0 while [[ "${attempt}" -lt "${BUDGET}" ]]; do attempt=$((attempt + 1)) tmp="$(mktemp)" - last_code="$(curl -sS --max-time 30 -o "${tmp}" -w '%{http_code}' "${SITE_URL}/api/health" || printf '000')" + last_code="$(curl -sS --max-time 30 -o "${tmp}" -w '%{http_code}' "${SITE_URL}/api/health" || true)" last_sha="$(python3 -c 'import json,sys try: print(json.load(open(sys.argv[1], encoding="utf-8")).get("sha") or "") From fe4bbc95fbcb2c816fe3ffcf9c1baded87c2b35b Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Sat, 26 Sep 2026 16:49:44 -0400 Subject: [PATCH 31/31] fix(cd): name the missing deploy prerequisites --- .github/workflows/deploy-api.yaml | 22 +++++++++++++++++++++- .github/workflows/deploy-web.yaml | 27 +++++++++++++++++++++++++-- README.md | 2 ++ scripts/test-terraform-dev-only.py | 3 +++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml index 0761dcc..6393492 100644 --- a/.github/workflows/deploy-api.yaml +++ b/.github/workflows/deploy-api.yaml @@ -131,6 +131,14 @@ jobs: echo "sha=${sha}" >> "${GITHUB_OUTPUT}" echo "Building ${sha}" + - name: Require deploy role + run: | + set -euo pipefail + if [ -z "${DEPLOY_ROLE_ARN}" ]; then + echo "DEPLOY_ROLE_ARN is empty. Create GitHub Environment dev and set it after the seahaven-ap-dev apply, then re-run." >&2 + exit 1 + fi + - name: Configure AWS credentials using OIDC uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 with: @@ -143,7 +151,19 @@ jobs: run: | set -euo pipefail get_param() { - aws ssm get-parameter --name "$1" --query Parameter.Value --output text + local name="$1" err value + err="$(mktemp)" + if ! value="$(aws ssm get-parameter --name "${name}" --query Parameter.Value --output text 2>"${err}")"; then + if grep -q ParameterNotFound "${err}"; then + echo "SSM parameter ${name} does not exist yet. Apply the seahaven-ap-dev workspace, then re-run this workflow." >&2 + else + cat "${err}" >&2 + fi + rm -f "${err}" + exit 1 + fi + rm -f "${err}" + printf '%s\n' "${value}" } CLUSTER=$(get_param /seahaven-ap/deploy/cluster) SERVICE=$(get_param /seahaven-ap/deploy/service) diff --git a/.github/workflows/deploy-web.yaml b/.github/workflows/deploy-web.yaml index 3e2ea96..a496646 100644 --- a/.github/workflows/deploy-web.yaml +++ b/.github/workflows/deploy-web.yaml @@ -124,6 +124,14 @@ jobs: echo "Deploying ${sha}" test -f placeholder/index.html + - name: Require deploy role + run: | + set -euo pipefail + if [ -z "${DEPLOY_ROLE_ARN}" ]; then + echo "DEPLOY_ROLE_ARN is empty. Create GitHub Environment dev and set it after the seahaven-ap-dev apply, then re-run." >&2 + exit 1 + fi + - name: Configure AWS credentials using OIDC uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0 with: @@ -135,8 +143,23 @@ jobs: id: deploy run: | set -euo pipefail - BUCKET=$(aws ssm get-parameter --name /seahaven-ap/deploy/bucket --query Parameter.Value --output text) - DIST_ID=$(aws ssm get-parameter --name /seahaven-ap/deploy/distribution-id --query Parameter.Value --output text) + get_param() { + local name="$1" err value + err="$(mktemp)" + if ! value="$(aws ssm get-parameter --name "${name}" --query Parameter.Value --output text 2>"${err}")"; then + if grep -q ParameterNotFound "${err}"; then + echo "SSM parameter ${name} does not exist yet. Apply the seahaven-ap-dev workspace, then re-run this workflow." >&2 + else + cat "${err}" >&2 + fi + rm -f "${err}" + exit 1 + fi + rm -f "${err}" + printf '%s\n' "${value}" + } + BUCKET=$(get_param /seahaven-ap/deploy/bucket) + DIST_ID=$(get_param /seahaven-ap/deploy/distribution-id) DOMAIN=$(aws cloudfront get-distribution --id "${DIST_ID}" --query Distribution.DomainName --output text) { echo "bucket=${BUCKET}" diff --git a/README.md b/README.md index 9b8c731..a7b2f3a 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ HCP Terraform workspace `seahaven-ap-dev` (project `seahaven-dev`) uses working The first apply creates secret `seahaven-ap/google-oidc` with `client_id` and `client_secret` set to `replace-me`. Replace both values in Secrets Manager, then re-run the HCP apply. A `terraform/**` change on `main` starts that apply. The Google IdP is not registered while the placeholder is still current. +The merge that adds these workflows can start Deploy Web and Deploy API before that apply has written `/seahaven-ap/deploy/*` and before GitHub Environment `dev` has `DEPLOY_ROLE_ARN`. Those runs fail on purpose until both exist. Re-run them after the apply. + - `.github/workflows/deploy-web.yaml` syncs `placeholder/` to the web bucket. It does not run `vite build`. - `.github/workflows/deploy-api.yaml` builds the API image with `GIT_SHA`, registers the task definition from `/seahaven-ap/deploy/task-environment`, migrates, and checks `GET /api/health`. diff --git a/scripts/test-terraform-dev-only.py b/scripts/test-terraform-dev-only.py index 0e64cc2..92d8957 100755 --- a/scripts/test-terraform-dev-only.py +++ b/scripts/test-terraform-dev-only.py @@ -54,6 +54,7 @@ def test_no_hcp_iam_and_no_prod(): assert 'local.google_oidc_client_secret != "replace-me"' in cognito readme = (ROOT / "README.md").read_text() assert "Replace both values in Secrets Manager, then re-run the HCP apply." in readme + assert "Those runs fail on purpose until both exist." in readme secrets = (tf_dir / "secrets.tf").read_text() assert 'resource "aws_secretsmanager_secret_version" "google_oidc"' in secrets assert "ignore_changes = [secret_string]" in secrets @@ -74,6 +75,8 @@ def test_deploy_workflows_are_dev_only(): assert "environment:prod" not in text assert "cancel-in-progress: false" in text assert "environment: ${{ needs.target.outputs.environment }}" in text + assert "DEPLOY_ROLE_ARN is empty" in text + assert "does not exist yet. Apply the seahaven-ap-dev workspace" in text web = (ROOT / ".github" / "workflows" / "deploy-web.yaml").read_text() assert "vite build" not in web assert "placeholder/" in web