feat(api): stand up Hono Drizzle foundation with auth and Redocly (AP-14) (#12)

* feat(api): stand up Hono Drizzle foundation with auth and Redocly

* fix(api): bump drizzle-orm and hono node-server past audit highs

* fix(api): harden auth upsert and Cognito token verification
This commit is contained in:
Adam Moussa 2026-08-10 20:10:49 -04:00 • committed by GitHub
parent 46b570352a
commit 9d3646876e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 4902 additions and 6 deletions

View file

@ -4,3 +4,31 @@ VITE_USE_MOCKS=true
# AP-22 visual parity uses Stampli wordmark when false/unset.
# AP-38 Sea Haven branding cutover: set true.
VITE_SEA_HAVEN_BRAND=false
# --- @seahaven-ap/api (AP-14) ---
API_PORT=8787
DATABASE_DRIVER=postgres
DATABASE_URL=postgresql://seahaven:seahaven@127.0.0.1:5432/seahaven_ap
# Local-only auth bypass. Allowed only when NODE_ENV is development or test.
DEV_AUTH_BYPASS=true
DEV_AUTH_SUB=seed-sub-admin
DEV_AUTH_EMAIL=admin@seahavenind.com
DEV_AUTH_NAME=Dev Admin
DEV_AUTH_ROLE=admin
# Cognito (required when DEV_AUTH_BYPASS=false)
# COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/<pool-id>
# COGNITO_AUDIENCE=<app-client-id>
# Aurora Data API driver (DATABASE_DRIVER=data-api)
# AWS_REGION=us-east-1
# RDS_CLUSTER_ARN=
# RDS_SECRET_ARN=
# RDS_DATABASE=seahaven_ap
# MinIO (docker compose) — used by AP-15 document uploads
MINIO_ENDPOINT=http://127.0.0.1:9000
MINIO_ACCESS_KEY=seahaven
MINIO_SECRET_KEY=seahavensecret
MINIO_BUCKET=seahaven-ap-documents

1
.npmrc Normal file
View file

@ -0,0 +1 @@
install-links=true

View file

@ -8,3 +8,5 @@ package-lock.json
src/router.ts
playwright-report
test-results
packages/api/drizzle
.redocly.lint-ignore.yaml

View file

@ -0,0 +1,5 @@
# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API.
# See https://redocly.com/docs/cli/ for more information.
#
# Intentionally empty for AP-14 foundation. Add justified ignores in the same
# style as Sea-Haven-Industries/procurement-ingest when needed.

View file

@ -8,17 +8,51 @@ npm workspaces:
- `@seahaven-ap/web` — Vite/React SPA (repo root)
- `@seahaven-ap/shared` — payment ladder, invoice helpers, pay-date parsers, CSV constants ([`packages/shared`](packages/shared))
- `@seahaven-ap/api` — reserved for AP-14 (not present yet)
- `@seahaven-ap/api` — Hono API, Drizzle schema, auth/RBAC ([`packages/api`](packages/api))
## Local development
### Frontend (mocks)
```bash
npm ci
cp .env.example .env # optional; defaults already use mocks
npm run dev
```
App serves at http://localhost:3000. `VITE_USE_MOCKS=true` is the default data path until the API is wired (`src/mocks`).
App serves at http://localhost:3000. `VITE_USE_MOCKS=true` is the default data path until AP-15 wires live API calls.
### API + data plane (AP-14)
```bash
docker compose up -d
cp .env.example .env
npm run db:migrate
npm run db:seed
npm run dev:api
```
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/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).
API roles (source of truth): `admin`, `ap_processor`, `approver`, `viewer`. Frontend mocks still use `ap_operator` until AP-15 remaps them.
### OpenAPI / Redocly
Linting uses the same `redocly.yaml` ruleset as `procurement-ingest`.
```bash
npm run lint:api
npm run docs:preview # builds HTML via redocly build-docs and opens it
```
## Verify

49
docker-compose.yml Normal file
View file

@ -0,0 +1,49 @@
services:
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: seahaven
POSTGRES_PASSWORD: seahaven
POSTGRES_DB: seahaven_ap
volumes:
- seahaven_ap_pg:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U seahaven -d seahaven_ap"]
interval: 5s
timeout: 5s
retries: 10
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: seahaven
MINIO_ROOT_PASSWORD: seahavensecret
volumes:
- seahaven_ap_minio:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 10
minio-init:
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
depends_on:
minio:
condition: service_started
entrypoint: >
/bin/sh -c "
until mc alias set local http://minio:9000 seahaven seahavensecret; do sleep 1; done;
mc mb --ignore-existing local/seahaven-ap-documents;
exit 0;
"
volumes:
seahaven_ap_pg:
seahaven_ap_minio:

View file

@ -20,7 +20,7 @@ export default tseslint.config(
...tseslint.configs.recommended,
eslintConfigPrettier,
{
files: ["packages/shared/src/**/*.ts"],
files: ["packages/shared/src/**/*.ts", "packages/api/src/**/*.ts"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",

1767
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,15 +8,23 @@
],
"scripts": {
"dev": "npm run build:shared && vite",
"dev:api": "npm run build:shared && npm run dev -w @seahaven-ap/api",
"generate:router": "node scripts/generate-router.mjs",
"build:shared": "npm run build -w @seahaven-ap/shared",
"build": "npm run build:shared && npm run generate:router && tsc -b && vite build",
"build:api": "npm run build:shared && npm run build -w @seahaven-ap/api",
"build": "npm run build:shared && npm run build:api && npm run generate:router && tsc -b && vite build",
"preview": "vite preview",
"test": "npm run test -w @seahaven-ap/shared && vitest run",
"db:migrate": "npm run db:migrate -w @seahaven-ap/api",
"db:seed": "npm run db:seed -w @seahaven-ap/api",
"db:generate": "npm run db:generate -w @seahaven-ap/api",
"test": "npm run test -w @seahaven-ap/shared && npm run test -w @seahaven-ap/api && vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"lint": "eslint . --max-warnings=0",
"lint": "eslint . --max-warnings=0 && npm run lint:api",
"lint:api": "redocly lint packages/api/openapi/openapi.yaml --config=redocly.yaml",
"lint:fix": "eslint . --fix --max-warnings=0",
"docs:build": "redocly build-docs packages/api/openapi/openapi.yaml --config=redocly.yaml -o /tmp/seahaven-ap-api-docs.html",
"docs:preview": "npm run docs:build && open /tmp/seahaven-ap-api-docs.html",
"format": "prettier --write .",
"format:check": "prettier --check .",
"verify": "npm run format:check && npm run lint && npm run build && npm test"
@ -45,6 +53,7 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.55.0",
"@redocly/cli": "^2.44.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.0",
"@types/node": "^24.3.0",

37
packages/api/docs/auth.md Normal file
View file

@ -0,0 +1,37 @@
# Authentication
## Cognito bearer tokens
Production and seahaven-dev expect a Bearer Cognito token verified against the
user pool JWKS and issuer.
Audience check:
- ID tokens (`token_use=id`): `aud` must equal `COGNITO_AUDIENCE` (app client id).
- 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).
Optional role claim mapping:
- `custom:role` or `role` → `role` (`admin`, `ap_processor`, `approver`, `viewer`)
- Missing role defaults to `viewer`
Users are upserted by `sub` only. An email already linked to a different `sub`
returns HTTP 409 and does not rebind the account.
## Local DEV_AUTH_BYPASS
For local development only, set `DEV_AUTH_BYPASS=true`. The middleware uses
`DEV_AUTH_SUB`, `DEV_AUTH_EMAIL`, `DEV_AUTH_NAME`, and `DEV_AUTH_ROLE` and
skips JWT verification.
Align `DEV_AUTH_SUB` with the seeded Cognito subject (default `seed-sub-admin`)
so local auth updates the seed user instead of conflicting on email.
`DEV_AUTH_BYPASS` is allowed only when `NODE_ENV` is `development` or `test`.
Any other value (including `production`, `staging`, and `preview`) rejects
startup.

View file

@ -0,0 +1,6 @@
# Sea Haven AP API
HTTP API for Sea Haven accounts payable (`ap.seahaven.com`).
This foundation documents the health and session smoke surface introduced in
AP-14. Domain CRUD lands in later tickets and extends this OpenAPI tree.

View file

@ -0,0 +1,38 @@
# Local development
## Data plane
```bash
docker compose up -d
npm run db:migrate
npm run db:seed
npm run dev:api
```
Defaults:
- Postgres at `postgresql://seahaven:seahaven@127.0.0.1:5432/seahaven_ap`
- API at `http://127.0.0.1:8787`
- MinIO at `http://127.0.0.1:9000` (documents bucket for AP-15)
## Smoke
```bash
curl -s http://127.0.0.1:8787/health
curl -s http://127.0.0.1:8787/api/me
```
With `DEV_AUTH_BYPASS=true` and `NODE_ENV=development` (or `test`), `/api/me`
does not require a Bearer token. Bypass is rejected for staging, preview,
production, and any other `NODE_ENV`.
## Docs
```bash
npm run lint:api
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.

View file

@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema/index.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgresql://seahaven:seahaven@127.0.0.1:5432/seahaven_ap",
},
});

View file

@ -0,0 +1,158 @@
CREATE TYPE "public"."approval_status" AS ENUM('pending', 'approved', 'rejected', 'skipped');--> statement-breakpoint
CREATE TYPE "public"."invoice_status" AS ENUM('pending_approval', 'approved', 'scheduled', 'paid', 'rejected', 'void');--> statement-breakpoint
CREATE TYPE "public"."payment_method" AS ENUM('check', 'ach');--> statement-breakpoint
CREATE TYPE "public"."payment_status" AS ENUM('scheduled', 'payment_submitted', 'issued', 'outstanding', 'cleared');--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('admin', 'ap_processor', 'approver', 'viewer');--> statement-breakpoint
CREATE TABLE "activity_log" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid NOT NULL,
"actor_user_id" uuid,
"message" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "approval_policies" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"priority" integer DEFAULT 100 NOT NULL,
"amount_threshold" numeric(14, 2),
"skip_below_amount" numeric(14, 2),
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "approval_steps" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid NOT NULL,
"policy_id" uuid,
"step_order" integer DEFAULT 1 NOT NULL,
"approver_role" "user_role" DEFAULT 'approver' NOT NULL,
"assignee_user_id" uuid,
"status" "approval_status" DEFAULT 'pending' NOT NULL,
"acted_by_user_id" uuid,
"acted_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "departments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" text NOT NULL,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "documents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid,
"object_key" text NOT NULL,
"content_type" text DEFAULT 'application/pdf' NOT NULL,
"file_name" text NOT NULL,
"uploaded_by_user_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "gl_accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" text NOT NULL,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invoice_comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid NOT NULL,
"author_user_id" uuid,
"body" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invoice_lines" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"invoice_id" uuid NOT NULL,
"description" text NOT NULL,
"amount" numeric(14, 2) NOT NULL,
"gl_account_id" uuid,
"department_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "invoices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"vendor_id" uuid NOT NULL,
"invoice_number" text NOT NULL,
"amount" numeric(14, 2) NOT NULL,
"amount_due" numeric(14, 2) NOT NULL,
"due_date" text NOT NULL,
"pay_date" text,
"send_payment_on" text,
"status" "invoice_status" DEFAULT 'pending_approval' NOT NULL,
"payment_method" "payment_method" DEFAULT 'check' NOT NULL,
"memo" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pay_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_by_user_id" uuid,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "payments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"pay_run_id" uuid,
"invoice_id" uuid,
"vendor_id" uuid,
"amount" numeric(14, 2) NOT NULL,
"payment_method" "payment_method" DEFAULT 'check' NOT NULL,
"check_number" integer,
"status" "payment_status" DEFAULT 'scheduled' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"cognito_sub" text NOT NULL,
"email" text NOT NULL,
"name" text NOT NULL,
"role" "user_role" DEFAULT 'viewer' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "vendors" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"email" text,
"default_payment_method" "payment_method" DEFAULT 'check' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "approval_steps" ADD CONSTRAINT "approval_steps_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "approval_steps" ADD CONSTRAINT "approval_steps_policy_id_approval_policies_id_fk" FOREIGN KEY ("policy_id") REFERENCES "public"."approval_policies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "approval_steps" ADD CONSTRAINT "approval_steps_assignee_user_id_users_id_fk" FOREIGN KEY ("assignee_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "approval_steps" ADD CONSTRAINT "approval_steps_acted_by_user_id_users_id_fk" FOREIGN KEY ("acted_by_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "documents" ADD CONSTRAINT "documents_uploaded_by_user_id_users_id_fk" FOREIGN KEY ("uploaded_by_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_comments" ADD CONSTRAINT "invoice_comments_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_comments" ADD CONSTRAINT "invoice_comments_author_user_id_users_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_lines" ADD CONSTRAINT "invoice_lines_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_lines" ADD CONSTRAINT "invoice_lines_gl_account_id_gl_accounts_id_fk" FOREIGN KEY ("gl_account_id") REFERENCES "public"."gl_accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoice_lines" ADD CONSTRAINT "invoice_lines_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_vendor_id_vendors_id_fk" FOREIGN KEY ("vendor_id") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pay_runs" ADD CONSTRAINT "pay_runs_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payments" ADD CONSTRAINT "payments_pay_run_id_pay_runs_id_fk" FOREIGN KEY ("pay_run_id") REFERENCES "public"."pay_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payments" ADD CONSTRAINT "payments_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "payments" ADD CONSTRAINT "payments_vendor_id_vendors_id_fk" FOREIGN KEY ("vendor_id") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "invoices_vendor_number_active_uidx" ON "invoices" USING btree ("vendor_id","invoice_number") WHERE "invoices"."status" <> 'void';--> statement-breakpoint
CREATE UNIQUE INDEX "users_cognito_sub_uidx" ON "users" USING btree ("cognito_sub");--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_uidx" ON "users" USING btree ("email");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1786404956920,
"tag": "0000_cheerful_peter_parker",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,54 @@
Error:
type: object
required:
- error
properties:
error:
type: string
description: Human-readable error message.
example: Missing or invalid Authorization header.
HealthResponse:
type: object
required:
- status
- database
properties:
status:
type: string
description: Process health marker.
example: ok
database:
type: string
description: Database ping result.
example: up
MeResponse:
type: object
required:
- id
- email
- name
- role
properties:
id:
type: string
format: uuid
description: Internal user primary key.
example: 11111111-1111-4111-8111-111111111111
email:
type: string
format: email
description: Caller email address.
example: admin@seahavenind.com
name:
type: string
description: Display name for the caller.
example: Dev Admin
role:
type: string
description: Authorization role for RBAC checks.
enum:
- admin
- ap_processor
- approver
- viewer
example: admin

View file

@ -0,0 +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.

View file

@ -0,0 +1,31 @@
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."
license:
name: Proprietary
servers:
- url: https://ap.seahaven.com
description: Production API host for ap.seahaven.com.
tags:
- name: Health
description: Liveness and dependency checks for the API process.
- name: Session
description: Authenticated caller identity after JWT or local dev auth.
paths:
/health:
$ref: ./paths/health.yaml
/api/me:
$ref: ./paths/me.yaml
components:
securitySchemes:
bearerAuth:
$ref: ./components/security.yaml#/bearerAuth
schemas:
Error:
$ref: ./components/schemas.yaml#/Error
HealthResponse:
$ref: ./components/schemas.yaml#/HealthResponse
MeResponse:
$ref: ./components/schemas.yaml#/MeResponse

View file

@ -0,0 +1,33 @@
get:
tags:
- Health
summary: Check API and database liveness
description: Returns ok when the process can ping the configured database.
operationId: get-health
security: []
responses:
"200":
description: API process is healthy and the database answered.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/HealthResponse
example:
status: ok
database: up
"400":
description: Bad request.
content:
application/json:
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.

View file

@ -0,0 +1,44 @@
get:
tags:
- Session
summary: Return the authenticated caller profile
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: []
responses:
"200":
description: Authenticated user profile.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/MeResponse
example:
id: 11111111-1111-4111-8111-111111111111
email: admin@seahavenind.com
name: Dev Admin
role: admin
"401":
description: Missing or invalid bearer token.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
example:
error: Missing or invalid Authorization header.
"409":
description: Email is already linked to a different Cognito subject.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
example:
error: Email admin@seahavenind.com is already linked to a different identity.
"404":
description: Not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
example:
error: Not found.

53
packages/api/package.json Normal file
View file

@ -0,0 +1,53 @@
{
"name": "@seahaven-ap/api",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Sea Haven AP Hono API — Drizzle schema, auth/RBAC, local data plane",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist",
"drizzle",
"openapi",
"docs"
],
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit --project tsconfig.json",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:seed": "tsx src/db/seed.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@aws-sdk/client-rds-data": "^3.848.0",
"@hono/node-server": "^2.1.0",
"@seahaven-ap/shared": "*",
"drizzle-orm": "^0.45.2",
"hono": "^4.13.1",
"jose": "^6.0.11",
"pg": "^8.16.3"
},
"devDependencies": {
"@faker-js/faker": "^9.9.0",
"@types/node": "^24.3.0",
"@types/pg": "^8.15.4",
"drizzle-kit": "^0.31.10",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=22.22.1"
}
}

View file

@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
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";
const sampleUser: AuthUser = {
id: "11111111-1111-4111-8111-111111111111",
cognitoSub: "seed-sub-admin",
email: "admin@seahavenind.com",
name: "Dev Admin",
role: "admin",
};
function createTestDb(options?: { pingFails?: boolean }): Db {
const userRow = {
id: sampleUser.id,
cognitoSub: sampleUser.cognitoSub,
email: sampleUser.email,
name: sampleUser.name,
role: sampleUser.role,
createdAt: new Date(),
updatedAt: new Date(),
};
const db = {
execute: vi.fn(async () => {
if (options?.pingFails) {
throw new Error("db down");
}
return [];
}),
query: {
users: {
findFirst: vi.fn(async () => userRow),
},
},
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => ({
returning: vi.fn(async () => [userRow]),
})),
})),
})),
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: vi.fn(async () => [userRow]),
})),
})),
};
return {
driver: "postgres",
pool: { end: vi.fn(async () => undefined) } as never,
db: db as never,
};
}
describe("createApp smoke routes", () => {
const env = loadEnv({
NODE_ENV: "test",
DEV_AUTH_BYPASS: "true",
DEV_AUTH_SUB: sampleUser.cognitoSub,
DEV_AUTH_EMAIL: sampleUser.email,
DEV_AUTH_NAME: sampleUser.name,
DEV_AUTH_ROLE: sampleUser.role,
});
it("GET /health returns ok when the database pings", async () => {
const app = createApp(env, createTestDb());
const response = await app.request("/health");
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 () => {
const app = createApp(env, createTestDb({ pingFails: true }));
const response = await app.request("/health");
expect(response.status).toBe(503);
await expect(response.json()).resolves.toEqual({ error: "Database is unavailable." });
});
it("GET /api/me returns the upserted caller under DEV_AUTH_BYPASS", async () => {
const app = createApp(env, createTestDb());
const response = await app.request("/api/me");
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
id: sampleUser.id,
email: sampleUser.email,
name: sampleUser.name,
role: sampleUser.role,
});
});
it("GET /api/me rejects missing bearer token when bypass is off", async () => {
const secureEnv = 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(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.",
});
});
});

28
packages/api/src/app.ts Normal file
View file

@ -0,0 +1,28 @@
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 { createHealthRoutes } from "./routes/health.js";
import { createMeRoutes } from "./routes/me.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.use("*", auth);
api.route("/", createMeRoutes());
app.route("/api", api);
app.notFound((c) => c.json({ error: "Not found." }, 404));
app.onError((error, c) => {
console.error(error);
return c.json({ error: "Internal server error." }, 500);
});
return app;
}
export type App = ReturnType<typeof createApp>;

View file

@ -0,0 +1,136 @@
import { createMiddleware } from "hono/factory";
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import type { AuthUser } from "./upsert-user.js";
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";
export type AppVariables = {
user: AuthUser;
};
export type AppBindings = {
Variables: AppVariables;
};
function roleFromClaims(claims: Record<string, unknown>, fallback: UserRole): UserRole {
const raw =
(typeof claims["custom:role"] === "string" && claims["custom:role"]) ||
(typeof claims.role === "string" && claims.role) ||
fallback;
return isUserRole(raw) ? raw : fallback;
}
function audienceMatches(payload: JWTPayload, expected: string): boolean {
const claims = payload as JWTPayload & { token_use?: string; client_id?: string };
if (claims.token_use === "access") {
return claims.client_id === expected;
}
if (typeof payload.aud === "string") {
return payload.aud === expected;
}
if (Array.isArray(payload.aud)) {
return payload.aud.includes(expected);
}
return false;
}
function identityFromPayload(payload: JWTPayload): {
sub: string;
email: string;
name: string;
} | null {
const sub = typeof payload.sub === "string" ? payload.sub : null;
const email = typeof payload.email === "string" ? payload.email : null;
const name =
(typeof payload.name === "string" && payload.name) ||
(typeof payload["cognito:username"] === "string" && payload["cognito:username"]) ||
email;
if (!sub || !email || !name) {
return null;
}
return { sub, email, name };
}
export function createAuthMiddleware(env: ApiEnv, handle: Db) {
const jwks =
env.cognitoIssuer.length > 0
? createRemoteJWKSet(new URL(`${env.cognitoIssuer}/.well-known/jwks.json`))
: null;
return createMiddleware<AppBindings>(async (c, next) => {
if (env.devAuthBypass) {
try {
const user = await upsertUserFromIdentity(handle, {
cognitoSub: env.devAuthSub,
email: env.devAuthEmail,
name: env.devAuthName,
role: env.devAuthRole,
});
c.set("user", user);
} catch (error) {
if (error instanceof IdentityConflictError) {
return c.json({ error: error.message }, 409);
}
throw error;
}
await next();
return;
}
const header = c.req.header("authorization");
if (!header?.startsWith("Bearer ")) {
return c.json({ error: "Missing or invalid Authorization header." }, 401);
}
if (!jwks) {
return c.json({ error: "JWT verification is not configured." }, 401);
}
const token = header.slice("Bearer ".length);
let payload: JWTPayload;
try {
({ payload } = await jwtVerify(token, jwks, {
issuer: env.cognitoIssuer,
}));
} catch {
return c.json({ error: "Invalid or expired token." }, 401);
}
if (!audienceMatches(payload, env.cognitoAudience)) {
return c.json({ error: "Token audience does not match this API." }, 401);
}
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.",
},
401,
);
}
let user: AuthUser;
try {
user = await upsertUserFromIdentity(handle, {
cognitoSub: identity.sub,
email: identity.email,
name: identity.name,
role: roleFromClaims(payload as Record<string, unknown>, "viewer"),
});
} catch (error) {
if (error instanceof IdentityConflictError) {
return c.json({ error: error.message }, 409);
}
throw error;
}
c.set("user", user);
await next();
});
}

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { can, RBAC_ACTIONS, type RbacAction } from "./rbac.js";
import { USER_ROLES, type UserRole } from "../env.js";
const EXPECTED: Record<UserRole, RbacAction[]> = {
admin: [...RBAC_ACTIONS],
ap_processor: ["read:me", "read:invoices", "write:invoices"],
approver: ["read:me", "read:invoices", "approve:invoices"],
viewer: ["read:me", "read:invoices"],
};
describe("RBAC matrix", () => {
it.each(USER_ROLES)("role %s matches the foundation matrix", (role) => {
for (const action of RBAC_ACTIONS) {
expect(can(role, action)).toBe(EXPECTED[role].includes(action));
}
});
it("denies viewer write and approve actions", () => {
expect(can("viewer", "write:invoices")).toBe(false);
expect(can("viewer", "approve:invoices")).toBe(false);
expect(can("viewer", "admin:settings")).toBe(false);
});
it("allows admin every foundation action", () => {
for (const action of RBAC_ACTIONS) {
expect(can("admin", action)).toBe(true);
}
});
});

View file

@ -0,0 +1,41 @@
import type { UserRole } from "../env.js";
/** Foundation + forward-looking actions used by the RBAC matrix. */
export const RBAC_ACTIONS = [
"read:me",
"read:invoices",
"write:invoices",
"approve:invoices",
"admin:settings",
] as const;
export type RbacAction = (typeof RBAC_ACTIONS)[number];
const MATRIX: Readonly<Record<UserRole, ReadonlySet<RbacAction>>> = {
admin: new Set(RBAC_ACTIONS),
ap_processor: new Set(["read:me", "read:invoices", "write:invoices"]),
approver: new Set(["read:me", "read:invoices", "approve:invoices"]),
viewer: new Set(["read:me", "read:invoices"]),
};
export function can(role: UserRole, action: RbacAction): boolean {
return MATRIX[role].has(action);
}
export function requireRole(role: UserRole, action: RbacAction): void {
if (!can(role, action)) {
throw new RbacDeniedError(role, action);
}
}
export class RbacDeniedError extends Error {
readonly status = 403 as const;
constructor(
readonly role: UserRole,
readonly action: RbacAction,
) {
super(`Role ${role} is not allowed to ${action}.`);
this.name = "RbacDeniedError";
}
}

View file

@ -0,0 +1,90 @@
import { describe, expect, it, vi } from "vitest";
import type { Db } from "../db/client.js";
import { IdentityConflictError, upsertUserFromIdentity } from "./upsert-user.js";
function createDb(options: {
bySub?: Record<string, unknown> | null;
byEmail?: Record<string, unknown> | null;
}): Db {
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) {
return options.bySub ?? null;
}
return options.byEmail ?? null;
});
const returningRow = {
id: "11111111-1111-4111-8111-111111111111",
cognitoSub: "seed-sub-admin",
email: "admin@seahavenind.com",
name: "Dev Admin",
role: "admin" as const,
};
return {
driver: "postgres",
pool: { end: vi.fn(async () => undefined) } as never,
db: {
query: { users: { findFirst } },
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => ({
returning: vi.fn(async () => [returningRow]),
})),
})),
})),
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: vi.fn(async () => [returningRow]),
})),
})),
} as never,
};
}
describe("upsertUserFromIdentity", () => {
it("updates an existing row matched by cognito sub", async () => {
const handle = createDb({
bySub: {
id: "11111111-1111-4111-8111-111111111111",
cognitoSub: "seed-sub-admin",
email: "admin@seahavenind.com",
name: "Old Name",
role: "admin",
},
});
const user = await upsertUserFromIdentity(handle, {
cognitoSub: "seed-sub-admin",
email: "admin@seahavenind.com",
name: "Dev Admin",
role: "admin",
});
expect(user.cognitoSub).toBe("seed-sub-admin");
expect(handle.db.update).toHaveBeenCalled();
});
it("refuses to rebind an email owned by a different cognito sub", async () => {
const handle = createDb({
bySub: null,
byEmail: {
id: "11111111-1111-4111-8111-111111111111",
cognitoSub: "other-sub",
email: "admin@seahavenind.com",
name: "Seed Admin",
role: "admin",
},
});
await expect(
upsertUserFromIdentity(handle, {
cognitoSub: "attacker-sub",
email: "admin@seahavenind.com",
name: "Attacker",
role: "admin",
}),
).rejects.toBeInstanceOf(IdentityConflictError);
});
});

View file

@ -0,0 +1,91 @@
import { eq } from "drizzle-orm";
import type { Db } from "../db/client.js";
import { users } from "../db/schema/index.js";
import type { UserRole } from "../env.js";
export type AuthIdentity = {
cognitoSub: string;
email: string;
name: string;
role: UserRole;
};
export type AuthUser = {
id: string;
cognitoSub: string;
email: string;
name: string;
role: UserRole;
};
export class IdentityConflictError extends Error {
readonly status = 409 as const;
constructor(readonly email: string) {
super(`Email ${email} is already linked to a different identity.`);
this.name = "IdentityConflictError";
}
}
function toAuthUser(row: {
id: string;
cognitoSub: string;
email: string;
name: string;
role: UserRole;
}): AuthUser {
return {
id: row.id,
cognitoSub: row.cognitoSub,
email: row.email,
name: row.name,
role: row.role,
};
}
/**
* Upsert by Cognito subject only. Never rebind an existing email to a new
* subject — that would allow account takeover if email claims collide.
*/
export async function upsertUserFromIdentity(
handle: Db,
identity: AuthIdentity,
): Promise<AuthUser> {
const bySub = await handle.db.query.users.findFirst({
where: eq(users.cognitoSub, identity.cognitoSub),
});
if (bySub) {
const [updated] = await handle.db
.update(users)
.set({
email: identity.email,
name: identity.name,
role: identity.role,
updatedAt: new Date(),
})
.where(eq(users.id, bySub.id))
.returning();
return toAuthUser(updated);
}
const byEmail = await handle.db.query.users.findFirst({
where: eq(users.email, identity.email),
});
if (byEmail) {
throw new IdentityConflictError(identity.email);
}
const [created] = await handle.db
.insert(users)
.values({
cognitoSub: identity.cognitoSub,
email: identity.email,
name: identity.name,
role: identity.role,
})
.returning();
return toAuthUser(created);
}

View file

@ -0,0 +1,51 @@
import { sql } from "drizzle-orm";
import { RDSDataClient } from "@aws-sdk/client-rds-data";
import { drizzle as drizzleDataApi } from "drizzle-orm/aws-data-api/pg";
import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
import pg from "pg";
import type { ApiEnv } from "../env.js";
import * as schema from "./schema/index.js";
export type PostgresDb = ReturnType<typeof createPostgresDb>;
export type DataApiDb = ReturnType<typeof createDataApiDb>;
export type Db = PostgresDb | DataApiDb;
function createPostgresDb(env: ApiEnv) {
const pool = new pg.Pool({ connectionString: env.databaseUrl });
return {
driver: "postgres" as const,
pool,
db: drizzlePg(pool, { schema }),
};
}
function createDataApiDb(env: ApiEnv) {
const client = new RDSDataClient({ region: env.awsRegion });
return {
driver: "data-api" as const,
db: drizzleDataApi(client, {
database: env.rdsDatabase,
secretArn: env.rdsSecretArn,
resourceArn: env.rdsClusterArn,
schema,
}),
};
}
export function createDb(env: ApiEnv): Db {
if (env.databaseDriver === "data-api") {
return createDataApiDb(env);
}
return createPostgresDb(env);
}
export async function pingDb(handle: Db): Promise<boolean> {
await handle.db.execute(sql`select 1`);
return true;
}
export async function closeDb(handle: Db): Promise<void> {
if (handle.driver === "postgres") {
await handle.pool.end();
}
}

View file

@ -0,0 +1,33 @@
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";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function main(): Promise<void> {
const env = loadEnv({
...process.env,
DEV_AUTH_BYPASS: process.env.DEV_AUTH_BYPASS ?? "true",
});
if (env.databaseDriver !== "postgres") {
throw new Error("db:migrate currently supports DATABASE_DRIVER=postgres only.");
}
const handle = createDb(env);
if (handle.driver !== "postgres") {
throw new Error("Expected postgres driver.");
}
const migrationsFolder = path.resolve(__dirname, "../../drizzle");
await migrate(handle.db, { migrationsFolder });
await closeDb(handle);
console.log("Migrations applied.");
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { INVOICE_STATUSES, PAYMENT_STATUSES } from "@seahaven-ap/shared";
import { invoiceStatusEnum, paymentStatusEnum, userRoleEnum } from "./index.js";
import { USER_ROLES } from "../../env.js";
describe("schema enums stay aligned with shared/env", () => {
it("matches USER_ROLES", () => {
expect([...userRoleEnum.enumValues]).toEqual([...USER_ROLES]);
});
it("matches INVOICE_STATUSES", () => {
expect([...invoiceStatusEnum.enumValues]).toEqual([...INVOICE_STATUSES]);
});
it("matches PAYMENT_STATUSES", () => {
expect([...paymentStatusEnum.enumValues]).toEqual([...PAYMENT_STATUSES]);
});
});

View file

@ -0,0 +1,198 @@
import {
boolean,
integer,
numeric,
pgEnum,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
/** Keep aligned with `@seahaven-ap/shared` and `USER_ROLES` in env.ts. */
export const userRoleEnum = pgEnum("user_role", ["admin", "ap_processor", "approver", "viewer"]);
export const invoiceStatusEnum = pgEnum("invoice_status", [
"pending_approval",
"approved",
"scheduled",
"paid",
"rejected",
"void",
]);
export const paymentStatusEnum = pgEnum("payment_status", [
"scheduled",
"payment_submitted",
"issued",
"outstanding",
"cleared",
]);
export const paymentMethodEnum = pgEnum("payment_method", ["check", "ach"]);
export const approvalStatusEnum = pgEnum("approval_status", [
"pending",
"approved",
"rejected",
"skipped",
]);
export const users = pgTable(
"users",
{
id: uuid("id").defaultRandom().primaryKey(),
cognitoSub: text("cognito_sub").notNull(),
email: text("email").notNull(),
name: text("name").notNull(),
role: userRoleEnum("role").notNull().default("viewer"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex("users_cognito_sub_uidx").on(table.cognitoSub),
uniqueIndex("users_email_uidx").on(table.email),
],
);
export const vendors = pgTable("vendors", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
email: text("email"),
defaultPaymentMethod: paymentMethodEnum("default_payment_method").notNull().default("check"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const glAccounts = pgTable("gl_accounts", {
id: uuid("id").defaultRandom().primaryKey(),
code: text("code").notNull(),
name: text("name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const departments = pgTable("departments", {
id: uuid("id").defaultRandom().primaryKey(),
code: text("code").notNull(),
name: text("name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const invoices = pgTable(
"invoices",
{
id: uuid("id").defaultRandom().primaryKey(),
vendorId: uuid("vendor_id")
.notNull()
.references(() => vendors.id),
invoiceNumber: text("invoice_number").notNull(),
amount: numeric("amount", { precision: 14, scale: 2 }).notNull(),
amountDue: numeric("amount_due", { precision: 14, scale: 2 }).notNull(),
dueDate: text("due_date").notNull(),
payDate: text("pay_date"),
sendPaymentOn: text("send_payment_on"),
status: invoiceStatusEnum("status").notNull().default("pending_approval"),
paymentMethod: paymentMethodEnum("payment_method").notNull().default("check"),
memo: text("memo").notNull().default(""),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex("invoices_vendor_number_active_uidx")
.on(table.vendorId, table.invoiceNumber)
.where(sql`${table.status} <> 'void'`),
],
);
export const invoiceLines = pgTable("invoice_lines", {
id: uuid("id").defaultRandom().primaryKey(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "cascade" }),
description: text("description").notNull(),
amount: numeric("amount", { precision: 14, scale: 2 }).notNull(),
glAccountId: uuid("gl_account_id").references(() => glAccounts.id),
departmentId: uuid("department_id").references(() => departments.id),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const invoiceComments = pgTable("invoice_comments", {
id: uuid("id").defaultRandom().primaryKey(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "cascade" }),
authorUserId: uuid("author_user_id").references(() => users.id),
body: text("body").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const activityLog = pgTable("activity_log", {
id: uuid("id").defaultRandom().primaryKey(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "cascade" }),
actorUserId: uuid("actor_user_id").references(() => users.id),
message: text("message").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const approvalPolicies = pgTable("approval_policies", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
priority: integer("priority").notNull().default(100),
amountThreshold: numeric("amount_threshold", { precision: 14, scale: 2 }),
skipBelowAmount: numeric("skip_below_amount", { precision: 14, scale: 2 }),
active: boolean("active").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const approvalSteps = pgTable("approval_steps", {
id: uuid("id").defaultRandom().primaryKey(),
invoiceId: uuid("invoice_id")
.notNull()
.references(() => invoices.id, { onDelete: "cascade" }),
policyId: uuid("policy_id").references(() => approvalPolicies.id),
stepOrder: integer("step_order").notNull().default(1),
approverRole: userRoleEnum("approver_role").notNull().default("approver"),
assigneeUserId: uuid("assignee_user_id").references(() => users.id),
status: approvalStatusEnum("status").notNull().default("pending"),
actedByUserId: uuid("acted_by_user_id").references(() => users.id),
actedAt: timestamp("acted_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const documents = pgTable("documents", {
id: uuid("id").defaultRandom().primaryKey(),
invoiceId: uuid("invoice_id").references(() => invoices.id, { onDelete: "cascade" }),
objectKey: text("object_key").notNull(),
contentType: text("content_type").notNull().default("application/pdf"),
fileName: text("file_name").notNull(),
uploadedByUserId: uuid("uploaded_by_user_id").references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const payRuns = pgTable("pay_runs", {
id: uuid("id").defaultRandom().primaryKey(),
createdByUserId: uuid("created_by_user_id").references(() => users.id),
status: text("status").notNull().default("draft"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const payments = pgTable("payments", {
id: uuid("id").defaultRandom().primaryKey(),
payRunId: uuid("pay_run_id").references(() => payRuns.id),
invoiceId: uuid("invoice_id").references(() => invoices.id),
vendorId: uuid("vendor_id").references(() => vendors.id),
amount: numeric("amount", { precision: 14, scale: 2 }).notNull(),
paymentMethod: paymentMethodEnum("payment_method").notNull().default("check"),
checkNumber: integer("check_number"),
status: paymentStatusEnum("status").notNull().default("scheduled"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});

161
packages/api/src/db/seed.ts Normal file
View file

@ -0,0 +1,161 @@
import { faker } from "@faker-js/faker";
import { closeDb, createDb } from "./client.js";
import { loadEnv, USER_ROLES, type UserRole } from "../env.js";
import {
activityLog,
approvalPolicies,
approvalSteps,
departments,
documents,
glAccounts,
invoiceComments,
invoiceLines,
invoices,
payRuns,
payments,
users,
vendors,
} from "./schema/index.js";
/** Stable UUIDs so re-seeds and docs stay deterministic. */
const IDS = {
users: {
admin: "11111111-1111-4111-8111-111111111111",
ap_processor: "22222222-2222-4222-8222-222222222222",
approver: "33333333-3333-4333-8333-333333333333",
viewer: "44444444-4444-4444-8444-444444444444",
} satisfies Record<UserRole, string>,
vendor: "55555555-5555-4555-8555-555555555555",
gl: "66666666-6666-4666-8666-666666666666",
department: "77777777-7777-4777-8777-777777777777",
invoice: "88888888-8888-4888-8888-888888888888",
line: "99999999-9999-4999-8999-999999999999",
comment: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
activity: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
policy: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
document: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
} as const;
const ROLE_EMAIL: Record<UserRole, string> = {
admin: "admin@seahavenind.com",
ap_processor: "ap.processor@seahavenind.com",
approver: "approver@seahavenind.com",
viewer: "viewer@seahavenind.com",
};
export async function seedDatabase(): Promise<void> {
faker.seed(14);
const env = loadEnv({
...process.env,
DEV_AUTH_BYPASS: process.env.DEV_AUTH_BYPASS ?? "true",
});
const handle = createDb(env);
const { db } = handle;
await db.delete(documents);
await db.delete(activityLog);
await db.delete(invoiceComments);
await db.delete(invoiceLines);
await db.delete(approvalSteps);
await db.delete(payments);
await db.delete(payRuns);
await db.delete(invoices);
await db.delete(approvalPolicies);
await db.delete(departments);
await db.delete(glAccounts);
await db.delete(vendors);
await db.delete(users);
await db.insert(users).values(
USER_ROLES.map((role) => ({
id: IDS.users[role],
cognitoSub: `seed-sub-${role}`,
email: ROLE_EMAIL[role],
name: faker.person.fullName(),
role,
})),
);
await db.insert(vendors).values({
id: IDS.vendor,
name: "Acme Facilities Supply",
email: "billing@acmefacilities.example",
defaultPaymentMethod: "check",
});
await db.insert(glAccounts).values({
id: IDS.gl,
code: "6100",
name: "Facilities Expense",
});
await db.insert(departments).values({
id: IDS.department,
code: "OPS",
name: "Operations",
});
await db.insert(approvalPolicies).values({
id: IDS.policy,
name: "Default approver policy",
priority: 10,
amountThreshold: "0.00",
skipBelowAmount: "25.00",
active: true,
});
await db.insert(invoices).values({
id: IDS.invoice,
vendorId: IDS.vendor,
invoiceNumber: "INV-1001",
amount: "1250.00",
amountDue: "1250.00",
dueDate: "2026-09-01",
payDate: null,
sendPaymentOn: null,
status: "pending_approval",
paymentMethod: "check",
memo: "Seed invoice for local smoke.",
});
await db.insert(invoiceLines).values({
id: IDS.line,
invoiceId: IDS.invoice,
description: "Monthly maintenance",
amount: "1250.00",
glAccountId: IDS.gl,
departmentId: IDS.department,
});
await db.insert(invoiceComments).values({
id: IDS.comment,
invoiceId: IDS.invoice,
authorUserId: IDS.users.ap_processor,
body: "Seed comment on INV-1001.",
});
await db.insert(activityLog).values({
id: IDS.activity,
invoiceId: IDS.invoice,
actorUserId: IDS.users.ap_processor,
message: "Invoice created from seed.",
});
await db.insert(documents).values({
id: IDS.document,
invoiceId: IDS.invoice,
objectKey: "seed/inv-1001.pdf",
contentType: "application/pdf",
fileName: "inv-1001.pdf",
uploadedByUserId: IDS.users.ap_processor,
});
await closeDb(handle);
console.log("Seed applied (faker seed=14).");
}
seedDatabase().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { loadEnv } from "./env.js";
describe("loadEnv", () => {
it("allows DEV_AUTH_BYPASS in development", () => {
const env = loadEnv({
NODE_ENV: "development",
DEV_AUTH_BYPASS: "true",
DEV_AUTH_ROLE: "viewer",
});
expect(env.devAuthBypass).toBe(true);
expect(env.devAuthRole).toBe("viewer");
expect(env.port).toBe(8787);
});
it("allows DEV_AUTH_BYPASS in test", () => {
const env = loadEnv({
NODE_ENV: "test",
DEV_AUTH_BYPASS: "true",
});
expect(env.devAuthBypass).toBe(true);
});
it("rejects DEV_AUTH_BYPASS in production", () => {
expect(() =>
loadEnv({
NODE_ENV: "production",
DEV_AUTH_BYPASS: "true",
}),
).toThrow(/DEV_AUTH_BYPASS/);
});
it("rejects DEV_AUTH_BYPASS in staging and other non-local envs", () => {
expect(() =>
loadEnv({
NODE_ENV: "staging",
DEV_AUTH_BYPASS: "true",
}),
).toThrow(/development or test/);
expect(() =>
loadEnv({
NODE_ENV: "preview",
DEV_AUTH_BYPASS: "true",
}),
).toThrow(/DEV_AUTH_BYPASS/);
});
it("requires Cognito config when bypass is off", () => {
expect(() =>
loadEnv({
NODE_ENV: "development",
DEV_AUTH_BYPASS: "false",
}),
).toThrow(/COGNITO_ISSUER/);
});
});

81
packages/api/src/env.ts Normal file
View file

@ -0,0 +1,81 @@
export const USER_ROLES = ["admin", "ap_processor", "approver", "viewer"] as const;
export type UserRole = (typeof USER_ROLES)[number];
export function isUserRole(value: string): value is UserRole {
return (USER_ROLES as readonly string[]).includes(value);
}
export type ApiEnv = {
nodeEnv: string;
port: number;
databaseDriver: "postgres" | "data-api";
databaseUrl: string;
awsRegion: string;
rdsClusterArn: string;
rdsSecretArn: string;
rdsDatabase: string;
cognitoIssuer: string;
cognitoAudience: string;
devAuthBypass: boolean;
devAuthSub: string;
devAuthEmail: string;
devAuthName: string;
devAuthRole: UserRole;
};
function required(name: string, value: string | undefined): string {
if (!value) {
throw new Error(`Missing required env var ${name}.`);
}
return value;
}
export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
const nodeEnv = env.NODE_ENV ?? "development";
const databaseDriver = (env.DATABASE_DRIVER ?? "postgres") as ApiEnv["databaseDriver"];
if (databaseDriver !== "postgres" && databaseDriver !== "data-api") {
throw new Error(`Invalid DATABASE_DRIVER: ${databaseDriver}`);
}
const devAuthBypass = env.DEV_AUTH_BYPASS === "true";
const localNodeEnvs = new Set(["development", "test"]);
if (devAuthBypass && !localNodeEnvs.has(nodeEnv)) {
throw new Error("DEV_AUTH_BYPASS is only allowed when NODE_ENV is development or test.");
}
const rawRole = env.DEV_AUTH_ROLE ?? "admin";
if (!isUserRole(rawRole)) {
throw new Error(`Invalid DEV_AUTH_ROLE: ${rawRole}`);
}
const base: ApiEnv = {
nodeEnv,
port: Number(env.API_PORT ?? "8787"),
databaseDriver,
databaseUrl: env.DATABASE_URL ?? "postgresql://seahaven:seahaven@127.0.0.1:5432/seahaven_ap",
awsRegion: env.AWS_REGION ?? "us-east-1",
rdsClusterArn: env.RDS_CLUSTER_ARN ?? "",
rdsSecretArn: env.RDS_SECRET_ARN ?? "",
rdsDatabase: env.RDS_DATABASE ?? "seahaven_ap",
cognitoIssuer: env.COGNITO_ISSUER ?? "",
cognitoAudience: env.COGNITO_AUDIENCE ?? "",
devAuthBypass,
devAuthSub: env.DEV_AUTH_SUB ?? "seed-sub-admin",
devAuthEmail: env.DEV_AUTH_EMAIL ?? "admin@seahavenind.com",
devAuthName: env.DEV_AUTH_NAME ?? "Dev Admin",
devAuthRole: rawRole,
};
if (databaseDriver === "data-api") {
required("RDS_CLUSTER_ARN", base.rdsClusterArn);
required("RDS_SECRET_ARN", base.rdsSecretArn);
}
if (!devAuthBypass && nodeEnv !== "test") {
required("COGNITO_ISSUER", base.cognitoIssuer);
required("COGNITO_AUDIENCE", base.cognitoAudience);
}
return base;
}

32
packages/api/src/index.ts Normal file
View file

@ -0,0 +1,32 @@
import { serve } from "@hono/node-server";
import { createApp } from "./app.js";
import { closeDb, createDb } from "./db/client.js";
import { loadEnv } from "./env.js";
async function main(): Promise<void> {
const env = loadEnv();
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 shutdown = async () => {
server.close();
await closeDb(handle);
process.exit(0);
};
process.on("SIGINT", () => {
void shutdown();
});
process.on("SIGTERM", () => {
void shutdown();
});
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -0,0 +1,18 @@
import { Hono } from "hono";
import type { Db } from "../db/client.js";
import { pingDb } from "../db/client.js";
export function createHealthRoutes(handle: Db) {
const routes = new Hono();
routes.get("/health", async (c) => {
try {
await pingDb(handle);
return c.json({ status: "ok", database: "up" });
} catch {
return c.json({ error: "Database is unavailable." }, 503);
}
});
return routes;
}

View file

@ -0,0 +1,23 @@
import { Hono } from "hono";
import type { AppBindings } from "../auth/middleware.js";
import { can } from "../auth/rbac.js";
export function createMeRoutes() {
const routes = new Hono<AppBindings>();
routes.get("/me", (c) => {
const user = c.get("user");
if (!can(user.role, "read:me")) {
return c.json({ error: "Forbidden." }, 403);
}
return c.json({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
});
});
return routes;
}

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false
},
"exclude": ["src/**/*.test.ts"]
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noEmitOnError": true,
"noEmit": true
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});

130
redocly.yaml Normal file
View file

@ -0,0 +1,130 @@
extends:
- recommended
apis:
seahaven-ap@v1:
root: packages/api/openapi/openapi.yaml
rules:
# Proprietary internal license -- no SPDX identifier or public URL exists.
info-license-strict: off
rule/info-title-api:
subject:
type: Info
property: title
assertions:
pattern: /.*API.*/
rule/info-description:
subject:
type: Info
property: description
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.
operation-4xx-problem-details-rfc7807: off
operation-operationId: error
rule/operationId-casing:
subject:
type: Operation
property: operationId
assertions:
casing: kebab-case
rule/operationId-prefix:
subject:
type: Operation
property: operationId
assertions:
pattern: /^GET|PUT|POST|DELETE|OPTIONS|HEAD|PATCH|TRACE/i
rule/operation-summary-period:
subject:
type: Operation
property: summary
assertions:
pattern: /[^.]$/
path-not-include-query: error
# No parameter-casing rule: path parameter names (workOrderId, poNumber,
# siteCode) are camelCase by contract -- they are baked into the API Gateway
# resource paths and read by the handler's pathParameters lookup.
rule/params-must-include-examples:
severity: error
subject:
type: Parameter
assertions:
requireAny:
- example
- examples
no-http-verbs-in-paths: error
no-ambiguous-paths: error
path-segment-plural:
severity: error
exceptions:
- docs
- openapi.json
- health
- me
- api
paths-kebab-case: error
no-invalid-schema-examples: error
# No schema-properties casing rule: property names mirror the DynamoDB
# items and the shipped SHOC webhook contract -- snake_case for WO/PO
# tables, camelCase for verified-sites (legacy, issue #24). Not lintable
# to one casing without a contract break.
# Error bodies must carry the top-level "error" field (the Error schema).
# 403 is exempt: it is emitted by API Gateway's SigV4 layer with AWS's
# {"message"} shape, not by the Lambda.
response-contains-property:
severity: error
names:
"400":
- error
"401":
- error
"404":
- error
"501":
- error
request-mime-type:
severity: error
allowedValues:
- application/json
response-mime-type:
severity: error
allowedValues:
- 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)
operation-singular-tag: error
operation-tag-defined: error
rule/tag-description:
subject:
type: Tag
property: description
assertions:
defined: true
rule/description-capitalization:
subject:
type: any
property: description
assertions:
pattern: /^([A-Z]|true|seahaven-prod)/
rule/description-punctuation:
subject:
type: any
property: description
assertions:
pattern: /(\.|server)$/
rule/avoid-words-in-descriptions:
subject:
type: any
property: description
assertions:
notPattern: /(simply|easy|easily|just|obviously|notethat)/i

View file

@ -12,6 +12,12 @@ export default defineConfig({
},
server: {
port: 3000,
proxy: {
"/api": {
target: "http://127.0.0.1:8787",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",