mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 04:33:15 +00:00
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:
parent
46b570352a
commit
9d3646876e
44 changed files with 4902 additions and 6 deletions
28
.env.example
28
.env.example
|
|
@ -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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
install-links=true
|
||||
|
|
@ -8,3 +8,5 @@ package-lock.json
|
|||
src/router.ts
|
||||
playwright-report
|
||||
test-results
|
||||
packages/api/drizzle
|
||||
.redocly.lint-ignore.yaml
|
||||
|
|
|
|||
5
.redocly.lint-ignore.yaml
Normal file
5
.redocly.lint-ignore.yaml
Normal 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.
|
||||
38
README.md
38
README.md
|
|
@ -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
49
docker-compose.yml
Normal 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:
|
||||
|
|
@ -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
1767
package-lock.json
generated
File diff suppressed because it is too large
Load diff
15
package.json
15
package.json
|
|
@ -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
37
packages/api/docs/auth.md
Normal 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.
|
||||
6
packages/api/docs/index.md
Normal file
6
packages/api/docs/index.md
Normal 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.
|
||||
38
packages/api/docs/local-dev.md
Normal file
38
packages/api/docs/local-dev.md
Normal 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.
|
||||
10
packages/api/drizzle.config.ts
Normal file
10
packages/api/drizzle.config.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
158
packages/api/drizzle/0000_cheerful_peter_parker.sql
Normal file
158
packages/api/drizzle/0000_cheerful_peter_parker.sql
Normal 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");
|
||||
1150
packages/api/drizzle/meta/0000_snapshot.json
Normal file
1150
packages/api/drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
13
packages/api/drizzle/meta/_journal.json
Normal file
13
packages/api/drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1786404956920,
|
||||
"tag": "0000_cheerful_peter_parker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
54
packages/api/openapi/components/schemas.yaml
Normal file
54
packages/api/openapi/components/schemas.yaml
Normal 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
|
||||
5
packages/api/openapi/components/security.yaml
Normal file
5
packages/api/openapi/components/security.yaml
Normal 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.
|
||||
31
packages/api/openapi/openapi.yaml
Normal file
31
packages/api/openapi/openapi.yaml
Normal 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
|
||||
33
packages/api/openapi/paths/health.yaml
Normal file
33
packages/api/openapi/paths/health.yaml
Normal 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.
|
||||
44
packages/api/openapi/paths/me.yaml
Normal file
44
packages/api/openapi/paths/me.yaml
Normal 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
53
packages/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
109
packages/api/src/app.test.ts
Normal file
109
packages/api/src/app.test.ts
Normal 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
28
packages/api/src/app.ts
Normal 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>;
|
||||
136
packages/api/src/auth/middleware.ts
Normal file
136
packages/api/src/auth/middleware.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
30
packages/api/src/auth/rbac.test.ts
Normal file
30
packages/api/src/auth/rbac.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
41
packages/api/src/auth/rbac.ts
Normal file
41
packages/api/src/auth/rbac.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
90
packages/api/src/auth/upsert-user.test.ts
Normal file
90
packages/api/src/auth/upsert-user.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
91
packages/api/src/auth/upsert-user.ts
Normal file
91
packages/api/src/auth/upsert-user.ts
Normal 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);
|
||||
}
|
||||
51
packages/api/src/db/client.ts
Normal file
51
packages/api/src/db/client.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
33
packages/api/src/db/migrate.ts
Normal file
33
packages/api/src/db/migrate.ts
Normal 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;
|
||||
});
|
||||
18
packages/api/src/db/schema/enums.test.ts
Normal file
18
packages/api/src/db/schema/enums.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
198
packages/api/src/db/schema/index.ts
Normal file
198
packages/api/src/db/schema/index.ts
Normal 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
161
packages/api/src/db/seed.ts
Normal 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;
|
||||
});
|
||||
57
packages/api/src/env.test.ts
Normal file
57
packages/api/src/env.test.ts
Normal 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
81
packages/api/src/env.ts
Normal 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
32
packages/api/src/index.ts
Normal 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;
|
||||
});
|
||||
18
packages/api/src/routes/health.ts
Normal file
18
packages/api/src/routes/health.ts
Normal 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;
|
||||
}
|
||||
23
packages/api/src/routes/me.ts
Normal file
23
packages/api/src/routes/me.ts
Normal 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;
|
||||
}
|
||||
7
packages/api/tsconfig.build.json
Normal file
7
packages/api/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false
|
||||
},
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
21
packages/api/tsconfig.json
Normal file
21
packages/api/tsconfig.json
Normal 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"]
|
||||
}
|
||||
8
packages/api/vitest.config.ts
Normal file
8
packages/api/vitest.config.ts
Normal 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
130
redocly.yaml
Normal 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
|
||||
|
|
@ -12,6 +12,12 @@ export default defineConfig({
|
|||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8787",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue