mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 09:13:17 +00:00
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:
parent
894a73dd13
commit
cc637e3732
21 changed files with 318 additions and 76 deletions
21
.dockerignore
Normal file
21
.dockerignore
Normal 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
27
Dockerfile
Normal 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"]
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } }`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
39
packages/api/openapi/paths/ready.yaml
Normal file
39
packages/api/openapi/paths/ready.yaml
Normal 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
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
9
packages/api/src/build-info.ts
Normal file
9
packages/api/src/build-info.ts
Normal 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() || "";
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
46
packages/api/src/http.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
16
redocly.yaml
16
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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue