From cc637e37327c61c7c24e53ae3bda283b5fce9f0d Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Tue, 22 Sep 2026 12:33:19 -0400 Subject: [PATCH] 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: