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.
This commit is contained in:
Adam Moussa 2026-09-22 12:33:19 -04:00
parent 894a73dd13
commit cc637e3732
No known key found for this signature in database
21 changed files with 318 additions and 76 deletions

21
.dockerignore Normal file
View file

@ -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

27
Dockerfile Normal file
View file

@ -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"]

View file

@ -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
```

View file

@ -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 } }`.

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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();
});
});

View file

@ -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<AppBindings>();
const auth = createAuthMiddleware(env, handle);
app.route("/", createHealthRoutes(handle));
const api = new Hono<AppBindings>();
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;

View file

@ -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;
}

View file

@ -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() || "";

View file

@ -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", () => {

View file

@ -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",

46
packages/api/src/http.ts Normal file
View file

@ -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);
}

View file

@ -8,8 +8,8 @@ async function main(): Promise<void> {
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 () => {

View file

@ -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.");
}
});

View file

@ -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<AppBindings>();
@ -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({

View file

@ -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: