feat(api): portal contract, cookie auth, and domain stubs (AP-51) (#64)

* feat(api): serve portal-shaped health and error envelope

Move liveness to GET /api/health { stage, sha } with a Node 24 image on 8080 so ALB probes and deploy verify do not need auth or a database ping.

* feat(api): switch live auth to host cookie BFF

Replace Bearer as the documented session path with Cognito hosted UI plus __Host-ap_* cookies so the SPA can call /api with credentials include.

* feat(web): add unused cookie SPA API client

Land a credentials-include fetch helper and hand-synced health/me types without wiring pages or domain hooks, so mocks stay the default data path.

* feat(api): add master-data OpenAPI and Hono stubs

* feat(api): add invoice, line, and document stubs

* feat(api): add approval policy, inbox, and activity stubs

* test(web): fix SPA client fetch mock types

* test(web): cast fetch mock call args for tsc

* fix(api): do not default DEV_AUTH_BYPASS outside local migrate

* fix(api): replace invoice lines in a single transaction

* fix(api): create invoices and lines in one transaction

* fix(api): inline GIT_SHA from the image build arg

* fix(api): stop PATCH from skipping the approval workflow

* fix(api): address review feedback

* fix(ci): format upsert-user test

* fix(api): document only the auth statuses the routes return

* fix(api): drop health 400 responses the routes never return
This commit is contained in:
Adam Moussa 2026-09-25 22:43:44 +00:00 • committed by GitHub
parent 81d5d5c97a
commit 864531b51e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 5992 additions and 153 deletions

21
.dockerignore Normal file
View file

@ -0,0 +1,21 @@
.git
.github
.cursor
dist
packages/*/dist
build
coverage
e2e
node_modules
src
public
placeholder
terraform
docs
*.md
.env
.env.*
playwright-report
test-results
.idea
.vscode

View file

@ -20,6 +20,9 @@ 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>
# COGNITO_DOMAIN=<hosted-ui-domain>
# APP_ORIGIN=http://127.0.0.1:3000
# ORIGIN_VERIFY_SECRET=
# Aurora Data API driver (DATABASE_DRIVER=data-api)
# AWS_REGION=us-east-1

View file

@ -1,5 +1,16 @@
# 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.
# Login and callback fail with a 302 redirect, or 500 when Cognito is not configured.
packages/api/openapi/paths/auth-login.yaml:
operation-4xx-response:
- '#/get/responses'
packages/api/openapi/paths/auth-callback.yaml:
operation-4xx-response:
- '#/get/responses'
# Health returns 200. Ready returns 200 or 503. Neither returns 4xx.
packages/api/openapi/paths/health.yaml:
operation-4xx-response:
- '#/get/responses'
packages/api/openapi/paths/ready.yaml:
operation-4xx-response:
- '#/get/responses'

27
Dockerfile Normal file
View file

@ -0,0 +1,27 @@
FROM --platform=$BUILDPLATFORM node:24-bookworm-slim@sha256:0e0ff40c39bc087845bfb27465a0df4ea419520094bc35842ff83dd8cbe6f9b6 AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/shared/package.json packages/shared/package.json
COPY packages/api/package.json packages/api/package.json
RUN npm ci
COPY packages/shared packages/shared
COPY packages/api packages/api
RUN npm run build:shared && npm run build -w @seahaven-ap/api
ARG GIT_SHA=unknown
RUN GIT_SHA="$GIT_SHA" node -e "require('node:fs').writeFileSync('packages/api/dist/build-info.js', 'export const BUILD_GIT_SHA = ' + JSON.stringify(process.env.GIT_SHA || 'unknown') + ';\\n')"
FROM --platform=linux/amd64 node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
RUN addgroup -S app && adduser -S -G app app
WORKDIR /app
COPY package.json package-lock.json ./
COPY packages/shared/package.json packages/shared/package.json
COPY packages/api/package.json packages/api/package.json
RUN npm ci --omit=dev
COPY --from=build /app/packages/shared/dist packages/shared/dist
COPY --from=build /app/packages/api/dist packages/api/dist
COPY --from=build /app/packages/api/drizzle packages/api/drizzle
USER app
ENV NODE_ENV=production \
API_PORT=8080
EXPOSE 8080
CMD ["node", "packages/api/dist/index.js"]

View file

@ -1,6 +1,6 @@
# Sea Haven AP
Internal accounts payable automation for Sea Haven Industries (`ap.seahaven.com`).
Internal accounts payable automation for Sea Haven Industries. Local API is `http://127.0.0.1:8787`. CloudFront on seahaven-dev is the first hosted origin.
## Workspace layout
@ -37,11 +37,11 @@ API listens on http://127.0.0.1:8787. Vite proxies `/api` to that port.
Smoke:
```bash
curl -s http://127.0.0.1:8787/health
curl -s http://127.0.0.1:8787/api/health
curl -s http://127.0.0.1:8787/api/me
```
`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).
`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). Cookie session names are `ap_*` locally and `__Host-ap_*` outside local.
API roles (source of truth): `admin`, `ap_processor`, `approver`, `viewer`. Frontend mocks still use `ap_operator` until AP-15 remaps them.

74
package-lock.json generated
View file

@ -116,6 +116,22 @@
"node": "20 || >=22"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1001.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.0.tgz",
"integrity": "sha512-6uTniZc87q+B5eXouGTl+7Tmc482rEeCcvxpsvREP8EfF0gvloRZ41UOA9sbSJlyy8TbqIBXb3kKfKarEArUQA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.0",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-rds-data": {
"version": "3.1136.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-rds-data/-/client-rds-data-3.1136.0.tgz",
@ -135,6 +151,28 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1137.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1137.0.tgz",
"integrity": "sha512-ppiYnDyy2qCDT3PIV83XJwAi0BhVUZ/jOHxUgC5tlMCaFeKRL8PVVub8A0pUMkF1yvZtzNOp3ClbmTCcIa9w3g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1001.0",
"@aws-sdk/core": "^3.978.0",
"@aws-sdk/credential-provider-node": "^3.972.83",
"@aws-sdk/middleware-sdk-s3": "^3.972.76",
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/fetch-http-handler": "^5.7.2",
"@smithy/node-http-handler": "^4.11.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.978.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.0.tgz",
@ -302,6 +340,23 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.76",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.76.tgz",
"integrity": "sha512-NfnTkVUTBKTBuBgqaapFK9r3YdkKt1b2oRvgLzZq91bwNKh6ZS0S7sEcheguttREaL4iyfs/xQnqD7Z7AsWSsA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.0",
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.45",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.45.tgz",
@ -321,6 +376,23 @@
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.1137.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1137.0.tgz",
"integrity": "sha512-OJQwS0qt5fQMoZSccncZQBLLvSZ3Jw7Lo+E3MYBNNPRnUkvJxn5LeY246K+1QvoL9o8zHpYVPa7YgCL+zf+xtg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.0",
"@aws-sdk/signature-v4-multi-region": "^3.996.46",
"@aws-sdk/types": "^3.974.5",
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz",
@ -7825,6 +7897,8 @@
"version": "0.1.0",
"dependencies": {
"@aws-sdk/client-rds-data": "^3.1135.0",
"@aws-sdk/client-s3": "^3.1137.0",
"@aws-sdk/s3-request-presigner": "^3.1137.0",
"@hono/node-server": "^2.1.1",
"@seahaven-ap/shared": "*",
"drizzle-orm": "^0.45.2",

View file

@ -1,9 +1,32 @@
# Authentication
## Cognito bearer tokens
## Cookie session (live path)
Production and seahaven-dev expect a Bearer Cognito token verified against the
user pool JWKS and issuer.
The API is a same-origin BFF. Cognito hosted UI issues tokens. The API stores
them in host-only cookies:
- `__Host-ap_at` access token (HttpOnly)
- `__Host-ap_it` ID token (HttpOnly)
- `__Host-ap_rt` refresh token (HttpOnly, path `/` when host-prefixed)
- `__Host-ap_sess` session hint (not HttpOnly; display name and email)
- `__Host-ap_oauth` PKCE state during login
Local `NODE_ENV` `development` or `test` drops the `__Host-` prefix and the
Secure flag so `http://127.0.0.1:8787` works (`ap_at`, `ap_it`, `ap_rt`).
Routes:
- `GET /api/auth/login`
- `GET /api/auth/callback`
- `POST /api/auth/refresh`
- `POST /api/auth/logout`
- `GET /api/me` reads the ID cookie, verifies it, and upserts `users` by `sub`
CloudFront sends `X-Origin-Verify` on `/api/*`. `GET /api/health` skips that
check so the ALB probe succeeds. Mutating `/api/*` requests also require a
matching `Origin`.
## Cognito tokens
Audience check:
@ -11,9 +34,7 @@ Audience check:
- 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).
`GET /api/me` uses the ID cookie.
Optional role claim mapping:

View file

@ -1,6 +1,7 @@
# Sea Haven AP API
HTTP API for Sea Haven accounts payable (`ap.seahaven.com`).
HTTP API for Sea Haven accounts payable. Local server is `http://127.0.0.1:8787`.
This foundation documents the health and session smoke surface introduced in
AP-14. Domain CRUD lands in later tickets and extends this OpenAPI tree.
Liveness is `GET /api/health` (`{ stage, sha }`) with no auth and no database
ping. Readiness is optional `GET /api/ready`. Errors use
`{ error: { code, message, correlationId } }`.

View file

@ -18,7 +18,7 @@ Defaults:
## Smoke
```bash
curl -s http://127.0.0.1:8787/health
curl -s http://127.0.0.1:8787/api/health
curl -s http://127.0.0.1:8787/api/me
```
@ -34,5 +34,5 @@ npm run docs:preview
```
`docs:preview` runs `redocly build-docs` (CLI v2) and opens the HTML at
`/tmp/seahaven-ap-api-docs.html`. Published OpenAPI servers point at
`https://ap.seahaven.com`. Use this page for the local docs view.
`/tmp/seahaven-ap-api-docs.html`. OpenAPI servers point at
`http://127.0.0.1:8787`. Use this page for the local docs view.

View file

@ -4,10 +4,39 @@ Error:
- error
properties:
error:
type: object
required:
- code
- message
- correlationId
properties:
code:
type: string
description: Machine-readable error code.
example: NOT_FOUND
message:
type: string
description: Human-readable error message.
example: Missing or invalid Authorization header.
example: Not found.
correlationId:
type: string
description: Request correlation identifier echoed from x-correlation-id when present.
example: 11111111-1111-4111-8111-111111111111
HealthResponse:
type: object
required:
- stage
- sha
properties:
stage:
type: string
description: Deployment stage name.
example: local
sha:
type: string
description: Git SHA inlined at image build.
example: deadbeef
ReadyResponse:
type: object
required:
- status
@ -15,7 +44,7 @@ HealthResponse:
properties:
status:
type: string
description: Process health marker.
description: Process readiness marker.
example: ok
database:
type: string
@ -52,3 +81,406 @@ MeResponse:
- approver
- viewer
example: admin
Vendor:
type: object
required:
- id
- name
- defaultPaymentMethod
- createdAt
- updatedAt
properties:
id:
type: string
format: uuid
description: Vendor primary key.
example: 55555555-5555-4555-8555-555555555555
name:
type: string
description: Vendor display name.
example: Acme Facilities Supply
email:
type: [string, "null"]
description: Billing email when present.
example: billing@acmefacilities.example
defaultPaymentMethod:
type: string
enum: [check, ach]
description: Default payment method for new invoices.
example: check
createdAt:
type: string
format: date-time
description: Row creation time.
updatedAt:
type: string
format: date-time
description: Row update time.
GlAccount:
type: object
required: [id, code, name, createdAt, updatedAt]
properties:
id:
type: string
format: uuid
description: GL account primary key.
example: 66666666-6666-4666-8666-666666666666
code:
type: string
description: Account code.
example: "6100"
name:
type: string
description: Account name.
example: Facilities Expense
createdAt:
type: string
format: date-time
description: Row creation time.
updatedAt:
type: string
format: date-time
description: Row update time.
Department:
type: object
required: [id, code, name, createdAt, updatedAt]
properties:
id:
type: string
format: uuid
description: Department primary key.
example: 77777777-7777-4777-8777-777777777777
code:
type: string
description: Department code.
example: OPS
name:
type: string
description: Department name.
example: Operations
createdAt:
type: string
format: date-time
description: Row creation time.
updatedAt:
type: string
format: date-time
description: Row update time.
User:
type: object
required: [id, email, name, role, createdAt, updatedAt]
properties:
id:
type: string
format: uuid
description: User primary key.
example: 11111111-1111-4111-8111-111111111111
email:
type: string
format: email
description: User email address.
example: admin@seahavenind.com
name:
type: string
description: Display name.
example: Dev Admin
role:
type: string
enum: [admin, ap_processor, approver, viewer]
description: Authorization role.
example: admin
createdAt:
type: string
format: date-time
description: Row creation time.
updatedAt:
type: string
format: date-time
description: Row update time.
Invoice:
type: object
required:
- id
- vendorId
- invoiceNumber
- amount
- amountDue
- dueDate
- status
- paymentMethod
- memo
- createdAt
- updatedAt
- lines
properties:
id:
type: string
format: uuid
description: Invoice primary key.
example: 88888888-8888-4888-8888-888888888888
vendorId:
type: string
format: uuid
description: Vendor foreign key.
example: 55555555-5555-4555-8555-555555555555
invoiceNumber:
type: string
description: Vendor-issued invoice number.
example: INV-1001
amount:
type: string
description: Invoice total as numeric(14,2) text.
example: "1250.00"
amountDue:
type: string
description: Remaining amount due as numeric(14,2) text.
example: "1250.00"
dueDate:
type: string
description: Due date as YYYY-MM-DD.
example: "2026-09-01"
payDate:
type: [string, "null"]
description: Optional pay date as YYYY-MM-DD.
example: "2026-09-15"
sendPaymentOn:
type: [string, "null"]
description: Optional send date as YYYY-MM-DD.
example: "2026-09-10"
status:
type: string
enum: [pending_approval, approved, scheduled, paid, rejected, void]
description: Invoice workflow status.
example: pending_approval
paymentMethod:
type: string
enum: [check, ach]
description: Payment method for this invoice.
example: check
memo:
type: string
description: Free-form memo.
example: Seed invoice for local smoke.
createdAt:
type: string
format: date-time
description: Row creation time.
updatedAt:
type: string
format: date-time
description: Row update time.
lines:
type: array
description: Coding lines attached to the invoice.
items:
$ref: "#/InvoiceLine"
InvoiceLine:
type: object
required: [id, invoiceId, description, amount, createdAt]
properties:
id:
type: string
format: uuid
description: Line primary key.
example: 99999999-9999-4999-8999-999999999999
invoiceId:
type: string
format: uuid
description: Parent invoice id.
example: 88888888-8888-4888-8888-888888888888
description:
type: string
description: Line description.
example: Monthly maintenance
amount:
type: string
description: Line amount as numeric(14,2) text.
example: "1250.00"
glAccountId:
type: [string, "null"]
format: uuid
description: Optional GL account id.
departmentId:
type: [string, "null"]
format: uuid
description: Optional department id.
createdAt:
type: string
format: date-time
description: Row creation time.
Document:
type: object
required: [id, objectKey, contentType, fileName, createdAt]
properties:
id:
type: string
format: uuid
description: Document primary key.
example: dddddddd-dddd-4ddd-8ddd-dddddddddddd
invoiceId:
type: [string, "null"]
format: uuid
description: Parent invoice id when attached.
objectKey:
type: string
description: Object key in the documents bucket.
example: seed/inv-1001.pdf
contentType:
type: string
description: Uploaded object MIME type.
example: application/pdf
fileName:
type: string
description: Original file name.
example: inv-1001.pdf
uploadedByUserId:
type: [string, "null"]
format: uuid
description: User who started the upload.
createdAt:
type: string
format: date-time
description: Row creation time.
uploadUrl:
type: string
description: Presigned PUT URL returned on create.
uploadHeaders:
type: object
additionalProperties:
type: string
description: Headers the browser must send with the presigned PUT.
downloadUrl:
type: string
description: Presigned GET URL returned on confirm or get.
ApprovalPolicy:
type: object
required: [id, name, priority, active, createdAt, updatedAt]
properties:
id:
type: string
format: uuid
description: Policy primary key.
example: cccccccc-cccc-4ccc-8ccc-cccccccccccc
name:
type: string
description: Policy display name.
example: Default approver policy
priority:
type: integer
description: Lower numbers match first.
example: 10
amountThreshold:
type: [string, "null"]
description: Minimum invoice amount that matches this policy.
example: "0.00"
skipBelowAmount:
type: [string, "null"]
description: Amounts below this skip the approval step.
example: "25.00"
active:
type: boolean
description: Whether the policy is considered when matching.
example: true
createdAt:
type: string
format: date-time
description: Row creation time.
updatedAt:
type: string
format: date-time
description: Row update time.
ApprovalStep:
type: object
required: [id, invoiceId, stepOrder, approverRole, status, createdAt]
properties:
id:
type: string
format: uuid
description: Step primary key.
example: eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee
invoiceId:
type: string
format: uuid
description: Parent invoice id.
policyId:
type: [string, "null"]
format: uuid
description: Policy that created the step.
stepOrder:
type: integer
description: Order within the invoice.
example: 1
approverRole:
type: string
enum: [admin, ap_processor, approver, viewer]
description: Role allowed to act when no assignee is set.
example: approver
assigneeUserId:
type: [string, "null"]
format: uuid
description: Optional assigned user.
status:
type: string
enum: [pending, approved, rejected, skipped]
description: Step status.
example: pending
actedByUserId:
type: [string, "null"]
format: uuid
description: User who acted.
actedAt:
type: [string, "null"]
format: date-time
description: When the step was acted on.
createdAt:
type: string
format: date-time
description: Row creation time.
InvoiceComment:
type: object
required: [id, invoiceId, body, createdAt]
properties:
id:
type: string
format: uuid
description: Comment primary key.
invoiceId:
type: string
format: uuid
description: Parent invoice id.
authorUserId:
type: [string, "null"]
format: uuid
description: Author user id.
body:
type: string
description: Comment text.
example: Looks good.
createdAt:
type: string
format: date-time
description: Row creation time.
ActivityLog:
type: object
required: [id, invoiceId, message, createdAt]
properties:
id:
type: string
format: uuid
description: Activity row primary key.
invoiceId:
type: string
format: uuid
description: Parent invoice id.
actorUserId:
type: [string, "null"]
format: uuid
description: Actor user id.
message:
type: string
description: Activity message.
example: Step approved.
createdAt:
type: string
format: date-time
description: Row creation time.

View file

@ -1,5 +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.
cookieAuth:
type: apiKey
in: cookie
name: __Host-ap_it
description: HttpOnly session id-token cookie set by GET /api/auth/callback. Local stage uses ap_it without the __Host- prefix.

View file

@ -2,30 +2,114 @@ openapi: 3.1.0
info:
title: Sea Haven AP API
version: 1.0.0
description: "Accounts payable HTTP API for Sea Haven Industries. Foundation surface for health and authenticated session smoke under local and AWS runtimes."
description: "Accounts payable HTTP API for Sea Haven Industries. Liveness, cookie session, and authenticated session smoke under local and AWS runtimes."
license:
name: Proprietary
servers:
- url: https://ap.seahaven.com
description: Production API host for ap.seahaven.com.
- url: http://127.0.0.1:8787
description: Local API process used by Vite proxy and unit tests.
tags:
- name: Health
description: Liveness and dependency checks for the API process.
description: Liveness and readiness checks for the API process.
- name: Session
description: Authenticated caller identity after JWT or local dev auth.
description: Cookie session via Cognito hosted UI, plus caller identity after upsert.
- name: Master data
description: Vendors, GL accounts, departments, and user role updates.
- name: Invoices
description: Invoice headers, coding lines, and uniqueness rules.
- name: Documents
description: Presigned document upload, confirm, and download against MinIO or S3.
- name: Approvals
description: Approval policies, step decisions, inbox, comments, and activity.
paths:
/health:
/api/health:
$ref: ./paths/health.yaml
/api/ready:
$ref: ./paths/ready.yaml
/api/auth/login:
$ref: ./paths/auth-login.yaml
/api/auth/callback:
$ref: ./paths/auth-callback.yaml
/api/auth/refresh:
$ref: ./paths/auth-refresh.yaml
/api/auth/logout:
$ref: ./paths/auth-logout.yaml
/api/me:
$ref: ./paths/me.yaml
/api/vendors:
$ref: ./paths/vendors.yaml
/api/vendors/{id}:
$ref: ./paths/vendors-id.yaml
/api/gl-accounts:
$ref: ./paths/gl-accounts.yaml
/api/gl-accounts/{id}:
$ref: ./paths/gl-accounts-id.yaml
/api/departments:
$ref: ./paths/departments.yaml
/api/departments/{id}:
$ref: ./paths/departments-id.yaml
/api/users:
$ref: ./paths/users.yaml
/api/users/{id}:
$ref: ./paths/users-id.yaml
/api/invoices:
$ref: ./paths/invoices.yaml
/api/invoices/{id}:
$ref: ./paths/invoices-id.yaml
/api/invoices/{id}/lines:
$ref: ./paths/invoices-id-lines.yaml
/api/invoices/{id}/documents:
$ref: ./paths/invoices-id-documents.yaml
/api/documents/{id}:
$ref: ./paths/documents-id.yaml
/api/documents/{id}/confirmations:
$ref: ./paths/documents-id-confirmations.yaml
/api/approval-policies:
$ref: ./paths/approval-policies.yaml
/api/approval-policies/{id}:
$ref: ./paths/approval-policies-id.yaml
/api/approval-steps/{id}/decisions:
$ref: ./paths/approval-steps-id-decisions.yaml
/api/inbox:
$ref: ./paths/inbox.yaml
/api/invoices/{id}/comments:
$ref: ./paths/invoices-id-comments.yaml
/api/invoices/{id}/activity-logs:
$ref: ./paths/invoices-id-activity-logs.yaml
components:
securitySchemes:
bearerAuth:
$ref: ./components/security.yaml#/bearerAuth
cookieAuth:
$ref: ./components/security.yaml#/cookieAuth
schemas:
Error:
$ref: ./components/schemas.yaml#/Error
HealthResponse:
$ref: ./components/schemas.yaml#/HealthResponse
ReadyResponse:
$ref: ./components/schemas.yaml#/ReadyResponse
MeResponse:
$ref: ./components/schemas.yaml#/MeResponse
Vendor:
$ref: ./components/schemas.yaml#/Vendor
GlAccount:
$ref: ./components/schemas.yaml#/GlAccount
Department:
$ref: ./components/schemas.yaml#/Department
User:
$ref: ./components/schemas.yaml#/User
Invoice:
$ref: ./components/schemas.yaml#/Invoice
InvoiceLine:
$ref: ./components/schemas.yaml#/InvoiceLine
Document:
$ref: ./components/schemas.yaml#/Document
ApprovalPolicy:
$ref: ./components/schemas.yaml#/ApprovalPolicy
ApprovalStep:
$ref: ./components/schemas.yaml#/ApprovalStep
InvoiceComment:
$ref: ./components/schemas.yaml#/InvoiceComment
ActivityLog:
$ref: ./components/schemas.yaml#/ActivityLog
security:
- cookieAuth: []

View file

@ -0,0 +1,91 @@
parameters:
- name: id
in: path
required: true
description: Policy primary key.
schema:
type: string
format: uuid
example: cccccccc-cccc-4ccc-8ccc-cccccccccccc
get:
tags: [Approvals]
summary: Get an approval policy
description: Returns one approval policy by id.
operationId: get-api-approval-policies-id
responses:
"200":
description: Policy.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/ApprovalPolicy
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Policy not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
patch:
tags: [Approvals]
summary: Update an approval policy
description: Admin-only patch. Requires admin:settings.
operationId: patch-api-approval-policies-id
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
example: Default approver policy
priority:
type: integer
example: 10
amountThreshold:
type: string
example: "0.00"
skipBelowAmount:
type: string
example: "25.00"
active:
type: boolean
example: true
responses:
"200":
description: Updated policy.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/ApprovalPolicy
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks admin:settings.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Policy not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,71 @@
get:
tags: [Approvals]
summary: List approval policies
description: Returns every approval policy.
operationId: get-api-approval-policies
responses:
"200":
description: Policy list.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/ApprovalPolicy
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
post:
tags: [Approvals]
summary: Create an approval policy
description: Admin-only insert. Requires admin:settings.
operationId: post-api-approval-policies
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
example: High dollar
priority:
type: integer
example: 1
amountThreshold:
type: string
example: "500.00"
skipBelowAmount:
type: string
example: "25.00"
active:
type: boolean
example: true
responses:
"201":
description: Created policy.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/ApprovalPolicy
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks admin:settings.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,57 @@
parameters:
- name: id
in: path
required: true
description: Approval step primary key.
schema:
type: string
format: uuid
example: eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee
post:
tags: [Approvals]
summary: Record an approval decision
description: Approves, rejects, or skips a pending step. Requires approve:invoices.
operationId: post-api-approval-steps-id-decisions
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action]
properties:
action:
type: string
enum: [approve, reject, skip]
example: approve
responses:
"200":
description: Updated step.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/ApprovalStep
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks approve:invoices.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Step not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"409":
description: Step is not pending.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,32 @@
get:
tags:
- Session
summary: Complete hosted UI sign-in
description: Exchanges the authorization code, sets HttpOnly session cookies, and redirects to returnTo.
operationId: get-api-auth-callback
security: []
parameters:
- name: code
in: query
required: false
description: Authorization code from Cognito.
schema:
type: string
example: abcdef
- name: state
in: query
required: false
description: PKCE state echoed from login.
schema:
type: string
example: state-token
- name: error
in: query
required: false
description: Cognito error code when sign-in failed.
schema:
type: string
example: access_denied
responses:
"302":
description: Redirect to the SPA or the login error page.

View file

@ -0,0 +1,30 @@
get:
tags:
- Session
summary: Start hosted UI sign-in
description: Redirects the browser to Cognito hosted UI with PKCE S256. Sets the oauth cookie.
operationId: get-api-auth-login
security: []
parameters:
- name: returnTo
in: query
required: false
description: Relative path to return to after sign-in.
schema:
type: string
default: /
example: /invoices
responses:
"302":
description: Redirect to Cognito hosted UI.
"500":
description: Cognito is not configured.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
example:
error:
code: INTERNAL_ERROR
message: Cognito is not configured.
correlationId: 11111111-1111-4111-8111-111111111111

View file

@ -0,0 +1,21 @@
post:
tags:
- Session
summary: End the API session
description: Clears session cookies. Idempotent when already signed out.
operationId: post-api-auth-logout
security: []
responses:
"204":
description: Session cleared.
"403":
description: CSRF origin check failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
example:
error:
code: FORBIDDEN
message: Origin is not allowed.
correlationId: 11111111-1111-4111-8111-111111111111

View file

@ -0,0 +1,32 @@
post:
tags:
- Session
summary: Refresh the session cookies
description: Rotates HttpOnly token cookies when the refresh token is still valid.
operationId: post-api-auth-refresh
security: []
responses:
"204":
description: Session refreshed.
"401":
description: Missing or invalid refresh token.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
example:
error:
code: UNAUTHENTICATED
message: Missing refresh token.
correlationId: 11111111-1111-4111-8111-111111111111
"403":
description: CSRF origin check failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
example:
error:
code: FORBIDDEN
message: Origin is not allowed.
correlationId: 11111111-1111-4111-8111-111111111111

View file

@ -0,0 +1,82 @@
parameters:
- name: id
in: path
required: true
description: Department primary key.
schema:
type: string
format: uuid
example: 77777777-7777-4777-8777-777777777777
get:
tags: [Master data]
summary: Get a department
description: Returns one department by id.
operationId: get-api-departments-id
responses:
"200":
description: Department.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Department
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Department not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
patch:
tags: [Master data]
summary: Update a department
description: Admin-only patch. Requires admin:settings.
operationId: patch-api-departments-id
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
code:
type: string
example: OPS
name:
type: string
example: Operations
responses:
"200":
description: Updated department.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Department
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks admin:settings.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Department not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,68 @@
get:
tags: [Master data]
summary: List departments
description: Returns every department row.
operationId: get-api-departments
responses:
"200":
description: Department list.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/Department
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
post:
tags: [Master data]
summary: Create a department
description: Admin-only insert. Requires admin:settings.
operationId: post-api-departments
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code, name]
properties:
code:
type: string
example: FIN
name:
type: string
example: Finance
responses:
"201":
description: Created department.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Department
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks admin:settings.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,52 @@
parameters:
- name: id
in: path
required: true
description: Document primary key.
schema:
type: string
format: uuid
example: dddddddd-dddd-4ddd-8ddd-dddddddddddd
post:
tags: [Documents]
summary: Confirm a document upload
description: Succeeds when the object exists in MinIO or S3. Missing objects are a conflict.
operationId: post-api-documents-id-confirmations
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
responses:
"200":
description: Confirmed document with download URL.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Document
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks write:invoices.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Document not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"409":
description: Object has not been uploaded.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,39 @@
parameters:
- name: id
in: path
required: true
description: Document primary key.
schema:
type: string
format: uuid
example: dddddddd-dddd-4ddd-8ddd-dddddddddddd
get:
tags: [Documents]
summary: Get a document
description: Returns document metadata and a presigned GET URL.
operationId: get-api-documents-id
responses:
"200":
description: Document with download URL.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Document
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Document not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,82 @@
parameters:
- name: id
in: path
required: true
description: GL account primary key.
schema:
type: string
format: uuid
example: 66666666-6666-4666-8666-666666666666
get:
tags: [Master data]
summary: Get a GL account
description: Returns one GL account by id.
operationId: get-api-gl-accounts-id
responses:
"200":
description: GL account.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/GlAccount
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: GL account not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
patch:
tags: [Master data]
summary: Update a GL account
description: Admin-only patch. Requires admin:settings.
operationId: patch-api-gl-accounts-id
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
code:
type: string
example: "6100"
name:
type: string
example: Facilities Expense
responses:
"200":
description: Updated GL account.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/GlAccount
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks admin:settings.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: GL account not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,68 @@
get:
tags: [Master data]
summary: List GL accounts
description: Returns every GL account row.
operationId: get-api-gl-accounts
responses:
"200":
description: GL account list.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/GlAccount
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
post:
tags: [Master data]
summary: Create a GL account
description: Admin-only insert. Requires admin:settings.
operationId: post-api-gl-accounts
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code, name]
properties:
code:
type: string
example: "6200"
name:
type: string
example: Utilities
responses:
"201":
description: Created GL account.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/GlAccount
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks admin:settings.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -1,33 +1,17 @@
get:
tags:
- Health
summary: Check API and database liveness
description: Returns ok when the process can ping the configured database.
operationId: get-health
summary: Check API process liveness
description: Returns stage and build SHA. ALB target-group probes call this without auth or a database ping.
operationId: get-api-health
security: []
responses:
"200":
description: API process is healthy and the database answered.
description: API process is up.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/HealthResponse
example:
status: ok
database: up
"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.
stage: local
sha: deadbeef

View file

@ -0,0 +1,24 @@
get:
tags: [Approvals]
summary: List the caller approval inbox
description: Returns pending steps visible to the caller.
operationId: get-api-inbox
responses:
"200":
description: Inbox items.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/ApprovalStep
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,45 @@
parameters:
- name: id
in: path
required: true
description: Invoice primary key.
schema:
type: string
format: uuid
example: 88888888-8888-4888-8888-888888888888
get:
tags: [Approvals]
summary: List invoice activity
description: Returns activity log rows for one invoice.
operationId: get-api-invoices-id-activity-logs
responses:
"200":
description: Activity list.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/ActivityLog
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Invoice not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,80 @@
parameters:
- name: id
in: path
required: true
description: Invoice primary key.
schema:
type: string
format: uuid
example: 88888888-8888-4888-8888-888888888888
get:
tags: [Approvals]
summary: List invoice comments
description: Returns comments on one invoice.
operationId: get-api-invoices-id-comments
responses:
"200":
description: Comment list.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/InvoiceComment
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Invoice not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
post:
tags: [Approvals]
summary: Add an invoice comment
description: Appends a comment and an activity row.
operationId: post-api-invoices-id-comments
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [body]
properties:
body:
type: string
example: Looks good.
responses:
"201":
description: Created comment.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/InvoiceComment
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Invoice not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,53 @@
parameters:
- name: id
in: path
required: true
description: Invoice primary key.
schema:
type: string
format: uuid
example: 88888888-8888-4888-8888-888888888888
post:
tags: [Documents]
summary: Presign a document upload
description: Creates a document row and returns a MinIO or S3 presigned PUT URL.
operationId: post-api-invoices-id-documents
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [fileName]
properties:
fileName:
type: string
example: inv-1001.pdf
contentType:
type: string
example: application/pdf
responses:
"201":
description: Document row with upload URL.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Document
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks write:invoices.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Invoice not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,123 @@
parameters:
- name: id
in: path
required: true
description: Invoice primary key.
schema:
type: string
format: uuid
example: 88888888-8888-4888-8888-888888888888
post:
tags: [Invoices]
summary: Add an invoice line
description: Appends one coding line. Sum is checked on replace, not on this add.
operationId: post-api-invoices-id-lines
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [description, amount]
properties:
description:
type: string
example: Extra coding
amount:
type: string
example: "25.00"
glAccountId:
type: string
format: uuid
example: 66666666-6666-4666-8666-666666666666
departmentId:
type: string
format: uuid
example: 77777777-7777-4777-8777-777777777777
responses:
"201":
description: Created line.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/InvoiceLine
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks write:invoices.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Invoice not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
put:
tags: [Invoices]
summary: Replace invoice lines
description: Replaces every coding line. The amounts must sum to the invoice amount.
operationId: put-api-invoices-id-lines
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
type: object
required: [description, amount]
properties:
description:
type: string
example: Labor
amount:
type: string
example: "1250.00"
glAccountId:
type: string
format: uuid
departmentId:
type: string
format: uuid
responses:
"200":
description: Replaced lines.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/InvoiceLine
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks write:invoices.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Invoice not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,107 @@
parameters:
- name: id
in: path
required: true
description: Invoice primary key.
schema:
type: string
format: uuid
example: 88888888-8888-4888-8888-888888888888
get:
tags: [Invoices]
summary: Get an invoice
description: Returns one invoice and its coding lines.
operationId: get-api-invoices-id
responses:
"200":
description: Invoice.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Invoice
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Invoice not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
patch:
tags: [Invoices]
summary: Update an invoice
description: Patch header fields. Duplicate active vendor plus invoice number is a conflict.
operationId: patch-api-invoices-id
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
vendorId:
type: string
format: uuid
example: 55555555-5555-4555-8555-555555555555
invoiceNumber:
type: string
example: INV-1001
amount:
type: string
example: "1250.00"
dueDate:
type: string
example: "2026-09-01"
status:
type: string
enum: [void]
description: The only status PATCH may set. Approval and payment statuses go through decision routes.
example: void
paymentMethod:
type: string
enum: [check, ach]
example: ach
memo:
type: string
example: Updated memo
responses:
"200":
description: Updated invoice.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Invoice
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks write:invoices.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Invoice not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"409":
description: Active vendor and invoice number already exist.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,106 @@
get:
tags: [Invoices]
summary: List invoices
description: Returns invoices with their coding lines.
operationId: get-api-invoices
responses:
"200":
description: Invoice list.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/Invoice
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
post:
tags: [Invoices]
summary: Create an invoice
description: Inserts an invoice. Line amounts must sum to amount when lines are sent. Duplicate active vendor plus invoice number is a conflict.
operationId: post-api-invoices
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [vendorId, invoiceNumber, amount, dueDate]
properties:
vendorId:
type: string
format: uuid
example: 55555555-5555-4555-8555-555555555555
invoiceNumber:
type: string
example: INV-2002
amount:
type: string
example: "100.00"
dueDate:
type: string
example: "2026-10-01"
paymentMethod:
type: string
enum: [check, ach]
example: check
memo:
type: string
example: Harbor repair
lines:
type: array
items:
type: object
required: [description, amount]
properties:
description:
type: string
example: Labor
amount:
type: string
example: "100.00"
glAccountId:
type: string
format: uuid
departmentId:
type: string
format: uuid
responses:
"201":
description: Created invoice.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Invoice
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks write:invoices.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"409":
description: Active vendor and invoice number already exist.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -5,7 +5,7 @@ get:
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: []
- cookieAuth: []
responses:
"200":
description: Authenticated user profile.
@ -19,13 +19,16 @@ get:
name: Dev Admin
role: admin
"401":
description: Missing or invalid bearer token.
description: Missing or invalid session cookie.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
example:
error: Missing or invalid Authorization header.
error:
code: UNAUTHENTICATED
message: Missing id token.
correlationId: 11111111-1111-4111-8111-111111111111
"409":
description: Email is already linked to a different Cognito subject.
content:
@ -33,7 +36,10 @@ get:
schema:
$ref: ../components/schemas.yaml#/Error
example:
error: Email admin@seahavenind.com is already linked to a different identity.
error:
code: CONFLICT
message: Email admin@seahavenind.com is already linked to a different identity.
correlationId: 11111111-1111-4111-8111-111111111111
"404":
description: Not found.
content:
@ -41,4 +47,7 @@ get:
schema:
$ref: ../components/schemas.yaml#/Error
example:
error: Not found.
error:
code: NOT_FOUND
message: Not found.
correlationId: 11111111-1111-4111-8111-111111111111

View file

@ -0,0 +1,28 @@
get:
tags:
- Health
summary: Check API database readiness
description: Pings the configured database. Not the ALB target. Optional for operators and deploy verify.
operationId: get-api-ready
security: []
responses:
"200":
description: Database answered.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/ReadyResponse
example:
status: ok
database: up
"503":
description: Database ping failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
example:
error:
code: DATABASE_UNAVAILABLE
message: Database is unavailable.
correlationId: 11111111-1111-4111-8111-111111111111

View file

@ -0,0 +1,81 @@
parameters:
- name: id
in: path
required: true
description: User primary key.
schema:
type: string
format: uuid
example: 11111111-1111-4111-8111-111111111111
get:
tags: [Master data]
summary: Get a user
description: Returns one user by id.
operationId: get-api-users-id
responses:
"200":
description: User.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/User
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: User not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
patch:
tags: [Master data]
summary: Update a user role
description: Admin-only role update. Does not rebind email across Cognito subjects.
operationId: patch-api-users-id
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [role]
properties:
role:
type: string
enum: [admin, ap_processor, approver, viewer]
example: approver
responses:
"200":
description: Updated user.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/User
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks admin:settings.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: User not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,24 @@
get:
tags: [Master data]
summary: List users
description: Returns every user profile. Identity upsert remains sub-keyed.
operationId: get-api-users
responses:
"200":
description: User list.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/User
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,86 @@
parameters:
- name: id
in: path
required: true
description: Vendor primary key.
schema:
type: string
format: uuid
example: 55555555-5555-4555-8555-555555555555
get:
tags: [Master data]
summary: Get a vendor
description: Returns one vendor by id.
operationId: get-api-vendors-id
responses:
"200":
description: Vendor.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Vendor
"400":
description: Invalid id.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Vendor not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
patch:
tags: [Master data]
summary: Update a vendor
description: Admin-only patch. Requires admin:settings.
operationId: patch-api-vendors-id
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
example: Harbor Maintenance LLC
email:
type: string
example: billing@harbor.example
defaultPaymentMethod:
type: string
enum: [check, ach]
example: check
responses:
"200":
description: Updated vendor.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Vendor
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks admin:settings.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"404":
description: Vendor not found.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -0,0 +1,72 @@
get:
tags: [Master data]
summary: List vendors
description: Returns every vendor row.
operationId: get-api-vendors
responses:
"200":
description: Vendor list.
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: ../components/schemas.yaml#/Vendor
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
post:
tags: [Master data]
summary: Create a vendor
description: Admin-only insert. Requires admin:settings.
operationId: post-api-vendors
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
example: Harbor Maintenance
email:
type: string
example: billing@harbor.example
defaultPaymentMethod:
type: string
enum: [check, ach]
example: ach
responses:
"201":
description: Created vendor.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Vendor
"400":
description: Validation failed.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"403":
description: Caller lacks admin:settings.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error
"401":
description: Missing session.
content:
application/json:
schema:
$ref: ../components/schemas.yaml#/Error

View file

@ -31,6 +31,8 @@
},
"dependencies": {
"@aws-sdk/client-rds-data": "^3.1135.0",
"@aws-sdk/client-s3": "^3.1137.0",
"@aws-sdk/s3-request-presigner": "^3.1137.0",
"@hono/node-server": "^2.1.1",
"@seahaven-ap/shared": "*",
"drizzle-orm": "^0.45.2",

View file

@ -3,6 +3,7 @@ import { createApp } from "./app.js";
import type { Db } from "./db/client.js";
import { loadEnv } from "./env.js";
import type { AuthUser } from "./auth/upsert-user.js";
import type { ErrorEnvelope } from "./http.js";
const sampleUser: AuthUser = {
id: "11111111-1111-4111-8111-111111111111",
@ -56,6 +57,14 @@ function createTestDb(options?: { pingFails?: boolean }): Db {
};
}
function expectEnvelope(body: unknown, code: string, message: string) {
const envelope = body as ErrorEnvelope;
expect(envelope.error.code).toBe(code);
expect(envelope.error.message).toBe(message);
expect(envelope.error.correlationId).toEqual(expect.any(String));
expect(envelope.error.correlationId.length).toBeGreaterThan(0);
}
describe("createApp smoke routes", () => {
const env = loadEnv({
NODE_ENV: "test",
@ -66,18 +75,25 @@ describe("createApp smoke routes", () => {
DEV_AUTH_ROLE: sampleUser.role,
});
it("GET /health returns ok when the database pings", async () => {
it("GET /api/health returns stage and sha without auth or a database ping", async () => {
const app = createApp(env, createTestDb({ pingFails: true }));
const response = await app.request("/api/health");
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ stage: "local", sha: "unknown" });
});
it("GET /api/ready returns ok when the database pings", async () => {
const app = createApp(env, createTestDb());
const response = await app.request("/health");
const response = await app.request("/api/ready");
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ status: "ok", database: "up" });
});
it("GET /health returns error payload when the database is down", async () => {
it("GET /api/ready returns the error envelope when the database is down", async () => {
const app = createApp(env, createTestDb({ pingFails: true }));
const response = await app.request("/health");
const response = await app.request("/api/ready");
expect(response.status).toBe(503);
await expect(response.json()).resolves.toEqual({ error: "Database is unavailable." });
expectEnvelope(await response.json(), "DATABASE_UNAVAILABLE", "Database is unavailable.");
});
it("GET /api/me returns the upserted caller under DEV_AUTH_BYPASS", async () => {
@ -92,7 +108,7 @@ describe("createApp smoke routes", () => {
});
});
it("GET /api/me rejects missing bearer token when bypass is off", async () => {
it("GET /api/me rejects missing session cookies when bypass is off", async () => {
const secureEnv = loadEnv({
NODE_ENV: "test",
DEV_AUTH_BYPASS: "false",
@ -102,8 +118,160 @@ describe("createApp smoke routes", () => {
const app = createApp(secureEnv, createTestDb());
const response = await app.request("/api/me");
expect(response.status).toBe(401);
await expect(response.json()).resolves.toEqual({
error: "Missing or invalid Authorization header.",
expectEnvelope(await response.json(), "UNAUTHENTICATED", "Missing id token.");
});
it("unknown paths return the 404 error envelope", async () => {
const app = createApp(env, createTestDb());
const response = await app.request("/does-not-exist");
expect(response.status).toBe(404);
expectEnvelope(await response.json(), "NOT_FOUND", "Not found.");
});
it("unhandled throws return the 500 error envelope", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const app = createApp(env, createTestDb());
app.get("/explode", () => {
throw new Error("boom");
});
const response = await app.request("/explode");
expect(response.status).toBe(500);
expectEnvelope(await response.json(), "INTERNAL_ERROR", "Internal server error.");
errorSpy.mockRestore();
});
});
describe("cookie BFF", () => {
function setCookies(response: Response): string[] {
const getSetCookie = response.headers.getSetCookie?.bind(response.headers);
if (getSetCookie) return getSetCookie();
const single = response.headers.get("set-cookie");
return single ? [single] : [];
}
it("GET /api/auth/login sets __Host-ap_oauth in a production-like NODE_ENV", async () => {
const env = loadEnv({
NODE_ENV: "production",
STAGE: "dev",
COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test",
COGNITO_AUDIENCE: "test-audience",
COGNITO_DOMAIN: "auth.dev.example",
APP_ORIGIN: "https://d111111abcdef8.cloudfront.net",
});
const app = createApp(env, createTestDb());
const response = await app.request("/api/auth/login");
expect(response.status).toBe(302);
const cookies = setCookies(response);
const oauth = cookies.find((item) => item.startsWith("__Host-ap_oauth="));
expect(oauth).toBeDefined();
expect(oauth).toContain("HttpOnly");
expect(oauth).toMatch(/(?:^|; )Secure(?:;|$)/);
expect(oauth).not.toMatch(/Domain=/i);
expect(response.headers.get("location")).toContain("https://auth.dev.example/oauth2/authorize");
});
it("GET /api/auth/login omits __Host- and Secure on the local bypass path", async () => {
const env = loadEnv({
NODE_ENV: "test",
DEV_AUTH_BYPASS: "true",
COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test",
COGNITO_AUDIENCE: "test-audience",
COGNITO_DOMAIN: "auth.dev.example",
});
const app = createApp(env, createTestDb());
const response = await app.request("/api/auth/login");
expect(response.status).toBe(302);
const cookies = setCookies(response);
const oauth = cookies.find((item) => item.startsWith("ap_oauth="));
expect(oauth).toBeDefined();
expect(oauth).toContain("HttpOnly");
expect(oauth).not.toMatch(/(?:^|; )Secure(?:;|$)/);
expect(cookies.some((item) => item.startsWith("__Host-ap_oauth="))).toBe(false);
});
it("GET /api/auth/callback sets __Host-ap_* token cookies", async () => {
const env = loadEnv({
NODE_ENV: "production",
STAGE: "dev",
COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test",
COGNITO_AUDIENCE: "test-audience",
COGNITO_DOMAIN: "auth.dev.example",
APP_ORIGIN: "https://d111111abcdef8.cloudfront.net",
});
const login = await createApp(env, createTestDb()).request(
"/api/auth/login?returnTo=/invoices",
);
const oauthCookie = setCookies(login).find((item) => item.startsWith("__Host-ap_oauth="));
expect(oauthCookie).toBeDefined();
const location = new URL(login.headers.get("location") ?? "");
const state = location.searchParams.get("state") ?? "";
const app = createApp(env, createTestDb(), {
tokens: {
exchangeCode: async () => ({
accessToken: "at",
idToken: "it",
refreshToken: "rt",
}),
refresh: async () => ({ accessToken: "at", idToken: "it", refreshToken: "rt" }),
revoke: async () => undefined,
},
});
const response = await app.request(`/api/auth/callback?code=abc&state=${state}`, {
headers: { cookie: oauthCookie!.split(";")[0] },
});
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe("/invoices");
const cookies = setCookies(response);
expect(
cookies.some((item) => item.startsWith("__Host-ap_at=") && item.includes("Secure")),
).toBe(true);
expect(cookies.some((item) => item.startsWith("__Host-ap_it="))).toBe(true);
expect(cookies.some((item) => item.startsWith("__Host-ap_rt="))).toBe(true);
});
it("GET /api/me succeeds with an id cookie and fails without", async () => {
const env = 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(env, createTestDb(), {
verifyToken: async () => ({
sub: sampleUser.cognitoSub,
email: sampleUser.email,
name: sampleUser.name,
aud: "test-audience",
token_use: "id",
}),
});
const missing = await app.request("/api/me");
expect(missing.status).toBe(401);
const ok = await app.request("/api/me", { headers: { cookie: "ap_it=fake-id-token" } });
expect(ok.status).toBe(200);
await expect(ok.json()).resolves.toEqual({
id: sampleUser.id,
email: sampleUser.email,
name: sampleUser.name,
role: sampleUser.role,
});
});
it("returns 403 when origin-verify is missing on non-health /api", async () => {
const env = loadEnv({
NODE_ENV: "test",
DEV_AUTH_BYPASS: "true",
ORIGIN_VERIFY_SECRET: "origin-secret",
});
const app = createApp(env, createTestDb());
const health = await app.request("/api/health");
expect(health.status).toBe(200);
const me = await app.request("/api/me");
expect(me.status).toBe(403);
expectEnvelope(await me.json(), "FORBIDDEN", "Origin is not allowed.");
const allowed = await app.request("/api/me", {
headers: { "x-origin-verify": "origin-secret" },
});
expect(allowed.status).toBe(200);
});
});

View file

@ -1,25 +1,68 @@
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 { createAuthMiddleware, type AppBindings, type AuthDeps } from "./auth/middleware.js";
import { createHealthRoutes } from "./routes/health.js";
import { createMeRoutes } from "./routes/me.js";
import { createAuthRoutes } from "./routes/auth.js";
import { createVendorRoutes } from "./routes/vendors.js";
import { createGlAccountRoutes } from "./routes/gl-accounts.js";
import { createDepartmentRoutes } from "./routes/departments.js";
import { createUserRoutes } from "./routes/users.js";
import { createInvoiceRoutes } from "./routes/invoices.js";
import { createApprovalRoutes } from "./routes/approvals.js";
import { createDocumentsStore, type DocumentsStore } from "./documents.js";
import { ApiError, errorJson } from "./http.js";
import { cloudFrontOriginAllowed } from "./auth/origin-verify.js";
import { csrfAllowed, isMutating } from "./auth/oauth.js";
import type { CognitoTokenClient } from "./auth/cognito.js";
export function createApp(env: ApiEnv, handle: Db) {
export type AppDeps = AuthDeps & {
tokens?: CognitoTokenClient;
documents?: DocumentsStore;
};
export function createApp(env: ApiEnv, handle: Db, deps: AppDeps = {}) {
const app = new Hono<AppBindings>();
const auth = createAuthMiddleware(env, handle);
app.route("/", createHealthRoutes(handle));
const auth = createAuthMiddleware(env, handle, deps);
const api = new Hono<AppBindings>();
api.use("*", async (c, next) => {
const path = new URL(c.req.url).pathname;
if (path === "/api/health") {
await next();
return;
}
if (!cloudFrontOriginAllowed(c, env.originVerifySecret || undefined)) {
return errorJson(c, 403, "FORBIDDEN", "Origin is not allowed.");
}
await next();
});
api.use("*", async (c, next) => {
if (isMutating(c.req.method) && !csrfAllowed(c, env)) {
return errorJson(c, 403, "FORBIDDEN", "Origin is not allowed.");
}
await next();
});
api.use("*", auth);
api.route("/", createHealthRoutes(env, handle));
api.route("/", createAuthRoutes(env, deps));
api.route("/", createMeRoutes());
api.route("/", createVendorRoutes(handle));
api.route("/", createGlAccountRoutes(handle));
api.route("/", createDepartmentRoutes(handle));
api.route("/", createUserRoutes(handle));
api.route("/", createInvoiceRoutes(handle, deps.documents ?? createDocumentsStore(env)));
api.route("/", createApprovalRoutes(handle));
app.route("/api", api);
app.notFound((c) => c.json({ error: "Not found." }, 404));
app.notFound((c) => errorJson(c, 404, "NOT_FOUND", "Not found."));
app.onError((error, c) => {
if (error instanceof ApiError) {
return errorJson(c, error.status, error.code, error.message);
}
console.error(error);
return c.json({ error: "Internal server error." }, 500);
return errorJson(c, 500, "INTERNAL_ERROR", "Internal server error.");
});
return app;

View file

@ -0,0 +1,95 @@
import { eq } from "drizzle-orm";
import type { DbHandle } from "./db/client.js";
import { activityLog, approvalPolicies, approvalSteps, invoices } from "./db/schema/index.js";
import { moneyCents, rowsOf } from "./routes/helpers.js";
type InvoiceRow = typeof invoices.$inferSelect;
type PolicyRow = typeof approvalPolicies.$inferSelect;
type StepRow = typeof approvalSteps.$inferSelect;
export async function appendActivity(
handle: DbHandle,
invoiceId: string,
actorUserId: string,
message: string,
) {
await handle.db.insert(activityLog).values({ invoiceId, actorUserId, message }).returning();
}
export async function applyMatchingPolicy(
handle: DbHandle,
invoice: InvoiceRow,
actorUserId: string,
): Promise<InvoiceRow> {
await appendActivity(handle, invoice.id, actorUserId, "Invoice created.");
const policies = (await rowsOf<PolicyRow>(handle, approvalPolicies))
.filter((policy) => policy.active)
.sort((a, b) => a.priority - b.priority);
const amount = moneyCents(invoice.amount);
const matched = policies.find((policy) => {
if (policy.amountThreshold == null) return true;
return amount >= moneyCents(policy.amountThreshold);
});
if (!matched) return invoice;
const skip = matched.skipBelowAmount != null && amount < moneyCents(matched.skipBelowAmount);
await handle.db
.insert(approvalSteps)
.values({
invoiceId: invoice.id,
policyId: matched.id,
stepOrder: 1,
approverRole: "approver",
status: skip ? "skipped" : "pending",
actedByUserId: skip ? actorUserId : null,
actedAt: skip ? new Date() : null,
})
.returning();
if (!skip) {
await appendActivity(
handle,
invoice.id,
actorUserId,
`Approval required by policy ${matched.name}.`,
);
return invoice;
}
await appendActivity(
handle,
invoice.id,
actorUserId,
`Step skipped because amount is below ${matched.skipBelowAmount}.`,
);
const [updated] = await handle.db
.update(invoices)
.set({ id: invoice.id, status: "approved", updatedAt: new Date() })
.where(eq(invoices.id, invoice.id))
.returning();
return updated ?? { ...invoice, status: "approved" };
}
export async function remainingPending(handle: DbHandle, invoiceId: string): Promise<StepRow[]> {
const rows = await rowsOf<StepRow>(handle, approvalSteps);
return rows.filter((row) => row.invoiceId === invoiceId && row.status === "pending");
}
export async function closePendingSteps(
handle: DbHandle,
invoiceId: string,
actorUserId: string | null,
): Promise<void> {
const pending = await remainingPending(handle, invoiceId);
const actedAt = new Date();
for (const step of pending) {
await handle.db
.update(approvalSteps)
.set({
id: step.id,
status: "skipped",
actedByUserId: actorUserId,
actedAt,
})
.where(eq(approvalSteps.id, step.id))
.returning();
}
}

View file

@ -0,0 +1,104 @@
export type TokenSet = {
accessToken: string;
idToken: string;
refreshToken?: string;
};
export type CognitoTokenClient = {
exchangeCode(input: { code: string; verifier: string; redirectUri: string }): Promise<TokenSet>;
refresh(refreshToken: string): Promise<TokenSet>;
revoke(refreshToken: string): Promise<void>;
};
export function hostedOrigin(domain: string): string {
const trimmed = domain.trim().replace(/\/$/, "");
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
export function authorizeUrl(input: {
domain: string;
clientId: string;
redirectUri: string;
state: string;
challenge: string;
}): string {
const url = new URL(`${hostedOrigin(input.domain)}/oauth2/authorize`);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", input.clientId);
url.searchParams.set("redirect_uri", input.redirectUri);
url.searchParams.set("scope", "openid email profile");
url.searchParams.set("state", input.state);
url.searchParams.set("code_challenge", input.challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("identity_provider", "Google");
return url.toString();
}
export function callbackRedirectUri(appOrigin: string): string {
return `${appOrigin.replace(/\/$/, "")}/api/auth/callback`;
}
export function createCognitoTokenClient(
config: { cognitoAudience: string; cognitoDomain: string },
fetchFn: typeof fetch = fetch,
): CognitoTokenClient {
return {
exchangeCode(input) {
return tokenRequest(config, fetchFn, {
grant_type: "authorization_code",
code: input.code,
code_verifier: input.verifier,
redirect_uri: input.redirectUri,
});
},
async refresh(refreshToken) {
return tokenRequest(config, fetchFn, {
grant_type: "refresh_token",
refresh_token: refreshToken,
});
},
async revoke(refreshToken) {
const domain = config.cognitoDomain;
const clientId = config.cognitoAudience;
if (!domain || !clientId) throw new Error("Cognito domain and client id are required");
const response = await fetchFn(`${hostedOrigin(domain)}/oauth2/revoke`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token: refreshToken, client_id: clientId }).toString(),
});
if (!response.ok && response.status !== 200) {
throw new Error(`revoke failed (${response.status})`);
}
},
};
}
async function tokenRequest(
config: { cognitoAudience: string; cognitoDomain: string },
fetchFn: typeof fetch,
fields: Record<string, string>,
): Promise<TokenSet> {
const domain = config.cognitoDomain;
const clientId = config.cognitoAudience;
if (!domain || !clientId) throw new Error("Cognito domain and client id are required");
const response = await fetchFn(`${hostedOrigin(domain)}/oauth2/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: clientId, ...fields }).toString(),
});
if (!response.ok) throw new Error(`token request failed (${response.status})`);
const body = (await response.json()) as {
access_token?: unknown;
id_token?: unknown;
refresh_token?: unknown;
};
if (typeof body.access_token !== "string" || typeof body.id_token !== "string") {
throw new Error("token response missing tokens");
}
return {
accessToken: body.access_token,
idToken: body.id_token,
refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : undefined,
};
}

View file

@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
ACCESS_MAX_AGE_SEC,
COOKIE_ACCESS,
COOKIE_ID,
COOKIE_OAUTH,
COOKIE_REFRESH,
COOKIE_SESSION_HINT,
REFRESH_MAX_AGE_SEC,
clearCookie,
clearedSessionCookies,
cookieNames,
parseCookies,
serializeCookie,
serializeOauthCookie,
sessionCookieValue,
tokenCookies,
} from "./cookies.js";
const host = cookieNames("dev");
const local = cookieNames("local");
describe("auth cookies", () => {
it("prefixes deployed cookies with __Host- and keeps local names unprefixed", () => {
expect(host).toEqual({
access: `__Host-${COOKIE_ACCESS}`,
id: `__Host-${COOKIE_ID}`,
refresh: `__Host-${COOKIE_REFRESH}`,
oauth: `__Host-${COOKIE_OAUTH}`,
hint: `__Host-${COOKIE_SESSION_HINT}`,
});
expect(local).toEqual({
access: COOKIE_ACCESS,
id: COOKIE_ID,
refresh: COOKIE_REFRESH,
oauth: COOKIE_OAUTH,
hint: COOKIE_SESSION_HINT,
});
});
it("sets HttpOnly, SameSite=Lax, Path=/, no Domain, and Secure outside local", () => {
const cookie = serializeCookie(host.access, "tok", {
maxAge: ACCESS_MAX_AGE_SEC,
stage: "dev",
});
expect(cookie.startsWith(`${host.access}=`)).toBe(true);
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Lax");
expect(cookie).toContain("Path=/");
expect(cookie).toMatch(/(?:^|; )Secure(?:;|$)/);
expect(cookie).not.toMatch(/Domain=/i);
expect(cookie).toContain(`Max-Age=${ACCESS_MAX_AGE_SEC}`);
});
it("omits Secure on local and scopes refresh to /api/auth", () => {
const cookie = serializeCookie(local.access, "tok", {
maxAge: ACCESS_MAX_AGE_SEC,
stage: "local",
});
expect(cookie).toContain("HttpOnly");
expect(cookie).not.toMatch(/(?:^|; )Secure(?:;|$)/);
expect(cookie).not.toMatch(/Domain=/i);
const refresh = tokenCookies(
{ accessToken: "a", idToken: "i", refreshToken: "r" },
"local",
).find((item) => item.startsWith(`${local.refresh}=`));
expect(refresh).toContain("Path=/api/auth");
expect(refresh).not.toMatch(/(?:^|; )Secure(?:;|$)/);
});
it("sets a non-HttpOnly session hint for 8h", () => {
const cookies = tokenCookies({ accessToken: "a", idToken: "i", refreshToken: "r" }, "dev");
const hint = cookies.find((item) => item.startsWith(`${host.hint}=`));
expect(hint).toContain(`${host.hint}=1`);
expect(hint).toContain(`Max-Age=${REFRESH_MAX_AGE_SEC}`);
expect(hint).not.toContain("HttpOnly");
expect(hint).toContain("SameSite=Lax");
expect(hint).toMatch(/(?:^|; )Secure(?:;|$)/);
});
it("ignores unprefixed session cookies on deployed stages", () => {
expect(
sessionCookieValue({ headers: { cookie: `${COOKIE_ACCESS}=legacy` } }, "access", "dev"),
).toBeUndefined();
expect(
sessionCookieValue(
{ headers: { cookie: `${host.access}=host; ${COOKIE_ACCESS}=legacy` } },
"access",
"dev",
),
).toBe("host");
});
it("reads unprefixed session cookies only on local", () => {
expect(
sessionCookieValue({ headers: { cookie: `${COOKIE_ACCESS}=local` } }, "access", "local"),
).toBe("local");
});
it("parses Cookie headers and keeps the first duplicate", () => {
expect(
parseCookies({
headers: { cookie: `${host.access}=first; ${host.access}=second` },
}),
).toEqual({ [host.access]: "first" });
});
it("clears cookies with Max-Age=0 and the same host-only attributes", () => {
const cookie = clearCookie(host.access, "dev");
expect(cookie).toContain("Max-Age=0");
expect(cookie).toContain("HttpOnly");
expect(cookie).not.toMatch(/Domain=/i);
expect(cookie).toMatch(/(?:^|; )Secure(?:;|$)/);
});
it("clears both __Host- and legacy ap_* cookies on deployed stages", () => {
const cookies = clearedSessionCookies("dev");
expect(
cookies.some((item) => item.startsWith(`${host.refresh}=`) && item.includes("Max-Age=0")),
).toBe(true);
expect(
cookies.some(
(item) =>
item.startsWith(`${COOKIE_REFRESH}=`) &&
item.includes("Max-Age=0") &&
item.includes("Path=/"),
),
).toBe(true);
});
it("encodes oauth state without a Domain attribute", () => {
const cookie = serializeOauthCookie({ state: "st", verifier: "ver", returnTo: "/" }, "dev");
expect(cookie.startsWith(`${host.oauth}=`)).toBe(true);
expect(cookie).toContain("HttpOnly");
expect(cookie).not.toMatch(/Domain=/i);
});
});

View file

@ -0,0 +1,248 @@
export const COOKIE_ACCESS = "ap_at";
export const COOKIE_ID = "ap_it";
export const COOKIE_REFRESH = "ap_rt";
export const COOKIE_OAUTH = "ap_oauth";
export const COOKIE_SESSION_HINT = "ap_sess";
export const ACCESS_MAX_AGE_SEC = 60 * 60;
export const REFRESH_MAX_AGE_SEC = 8 * 60 * 60;
export const OAUTH_MAX_AGE_SEC = 10 * 60;
export type CookieEvent = {
cookies?: string[];
headers?: Record<string, string | undefined>;
};
export type OauthCookie = {
state: string;
verifier: string;
returnTo: string;
};
export type CookieNames = {
access: string;
id: string;
refresh: string;
oauth: string;
hint: string;
};
export type SessionHint = {
displayName: string;
email: string;
};
export function cookieNames(stage: string): CookieNames {
const prefix = stage === "local" ? "" : "__Host-";
return {
access: `${prefix}${COOKIE_ACCESS}`,
id: `${prefix}${COOKIE_ID}`,
refresh: `${prefix}${COOKIE_REFRESH}`,
oauth: `${prefix}${COOKIE_OAUTH}`,
hint: `${prefix}${COOKIE_SESSION_HINT}`,
};
}
export function parseCookies(event: CookieEvent): Record<string, string> {
const parsed: Record<string, string> = {};
for (const part of cookieParts(event)) {
const eq = part.indexOf("=");
if (eq <= 0) continue;
const name = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (!name) continue;
if (Object.prototype.hasOwnProperty.call(parsed, name)) continue;
parsed[name] = decodeCookieValue(value);
}
return parsed;
}
export function cookieValue(event: CookieEvent, name: string): string | undefined {
const value = parseCookies(event)[name];
return value ? value : undefined;
}
export function sessionCookieValue(
event: CookieEvent,
kind: keyof CookieNames,
stage: string,
): string | undefined {
return cookieValue(event, cookieNames(stage)[kind]);
}
export function serializeCookie(
name: string,
value: string,
options: { maxAge: number; stage: string; path?: string; httpOnly?: boolean },
): string {
const hostPrefixed = name.startsWith("__Host-");
const path = hostPrefixed ? "/" : (options.path ?? "/");
const parts = [
`${name}=${encodeURIComponent(value)}`,
`Path=${path}`,
"SameSite=Lax",
`Max-Age=${options.maxAge}`,
];
if (options.httpOnly !== false) parts.splice(2, 0, "HttpOnly");
if (hostPrefixed || options.stage !== "local") parts.push("Secure");
return parts.join("; ");
}
export function clearCookie(
name: string,
stage: string,
options: { httpOnly?: boolean } = {},
): string {
return serializeCookie(name, "", {
maxAge: 0,
stage,
path: cookiePath(name),
httpOnly: options.httpOnly,
});
}
export function tokenCookies(
tokens: { accessToken: string; idToken: string; refreshToken?: string },
stage: string,
): string[] {
const names = cookieNames(stage);
const cookies = [
serializeCookie(names.access, tokens.accessToken, { maxAge: ACCESS_MAX_AGE_SEC, stage }),
serializeCookie(names.id, tokens.idToken, { maxAge: ACCESS_MAX_AGE_SEC, stage }),
];
if (tokens.refreshToken) {
cookies.push(
serializeCookie(names.refresh, tokens.refreshToken, {
maxAge: REFRESH_MAX_AGE_SEC,
stage,
path: cookiePath(names.refresh),
}),
);
}
cookies.push(
serializeCookie(names.hint, sessionHintValue(tokens.idToken), {
maxAge: REFRESH_MAX_AGE_SEC,
stage,
httpOnly: false,
}),
);
cookies.push(clearCookie(names.oauth, stage));
return cookies;
}
export function clearedSessionCookies(stage: string): string[] {
const names = cookieNames(stage);
const cookies = [
clearCookie(names.access, stage),
clearCookie(names.id, stage),
clearCookie(names.refresh, stage),
clearCookie(names.oauth, stage),
clearCookie(names.hint, stage, { httpOnly: false }),
];
if (stage !== "local") {
const legacy = cookieNames("local");
cookies.push(
serializeCookie(legacy.access, "", { maxAge: 0, stage, path: "/" }),
serializeCookie(legacy.id, "", { maxAge: 0, stage, path: "/" }),
serializeCookie(legacy.refresh, "", { maxAge: 0, stage, path: "/" }),
serializeCookie(legacy.oauth, "", { maxAge: 0, stage, path: "/" }),
serializeCookie(legacy.hint, "", { maxAge: 0, stage, path: "/", httpOnly: false }),
);
}
return cookies;
}
export function serializeOauthCookie(payload: OauthCookie, stage: string): string {
const names = cookieNames(stage);
return serializeCookie(names.oauth, encodeOauth(payload), { maxAge: OAUTH_MAX_AGE_SEC, stage });
}
export function readOauthCookie(event: CookieEvent, stage: string): OauthCookie | undefined {
const raw = sessionCookieValue(event, "oauth", stage);
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw) as Partial<OauthCookie>;
if (
typeof parsed.state !== "string" ||
!parsed.state ||
typeof parsed.verifier !== "string" ||
!parsed.verifier ||
typeof parsed.returnTo !== "string"
) {
return undefined;
}
return { state: parsed.state, verifier: parsed.verifier, returnTo: parsed.returnTo };
} catch {
return undefined;
}
}
export function headerFrom(event: CookieEvent, name: string): string | undefined {
const headers = event.headers ?? {};
const needle = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === needle) return value;
}
return undefined;
}
export function cookieEventFromHeader(cookieHeader: string | undefined): CookieEvent {
return { headers: { cookie: cookieHeader } };
}
export function sessionHintFromIdToken(token: string): SessionHint | null {
const parts = token.split(".");
if (parts.length !== 3) return null;
try {
const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<
string,
unknown
>;
const email =
typeof claims.email === "string" && claims.email.includes("@") ? claims.email : "";
if (!email) return null;
const displayName =
(typeof claims.name === "string" && claims.name) ||
(typeof claims.given_name === "string" && claims.given_name) ||
email;
return { email, displayName };
} catch {
return null;
}
}
function cookiePath(name: string): string {
if (name.startsWith("__Host-")) return "/";
return name.endsWith(COOKIE_REFRESH) ? "/api/auth" : "/";
}
function sessionHintValue(idToken: string): string {
const hint = sessionHintFromIdToken(idToken);
return hint ? JSON.stringify(hint) : "1";
}
function cookieParts(event: CookieEvent): string[] {
const parts: string[] = [];
for (const cookie of event.cookies ?? []) {
parts.push(cookie);
}
const header = headerFrom(event, "cookie");
if (header) {
for (const part of header.split(";")) {
if (part.trim()) parts.push(part);
}
}
return parts;
}
function decodeCookieValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function encodeOauth(payload: OauthCookie): string {
return JSON.stringify(payload);
}

View file

@ -5,6 +5,9 @@ import { IdentityConflictError, upsertUserFromIdentity } from "./upsert-user.js"
import type { ApiEnv, UserRole } from "../env.js";
import { isUserRole } from "../env.js";
import type { Db } from "../db/client.js";
import { errorJson } from "../http.js";
import { cookieEventFromHeader, sessionCookieValue } from "./cookies.js";
import { cookieStage, isPublicRoute } from "./oauth.js";
export type AppVariables = {
user: AuthUser;
@ -14,12 +17,19 @@ export type AppBindings = {
Variables: AppVariables;
};
function roleFromClaims(claims: Record<string, unknown>, fallback: UserRole): UserRole {
export type TokenVerifier = (token: string) => Promise<JWTPayload>;
export type AuthDeps = {
verifyToken?: TokenVerifier;
};
function roleFromClaims(claims: Record<string, unknown>): UserRole | undefined {
const raw =
(typeof claims["custom:role"] === "string" && claims["custom:role"]) ||
(typeof claims.role === "string" && claims.role) ||
fallback;
return isUserRole(raw) ? raw : fallback;
undefined;
if (raw === undefined) return undefined;
return isUserRole(raw) ? raw : undefined;
}
function audienceMatches(payload: JWTPayload, expected: string): boolean {
@ -55,13 +65,31 @@ function identityFromPayload(payload: JWTPayload): {
return { sub, email, name };
}
export function createAuthMiddleware(env: ApiEnv, handle: Db) {
export function createAuthMiddleware(env: ApiEnv, handle: Db, deps: AuthDeps = {}) {
const jwks =
env.cognitoIssuer.length > 0
? createRemoteJWKSet(new URL(`${env.cognitoIssuer}/.well-known/jwks.json`))
: null;
const verifyToken: TokenVerifier =
deps.verifyToken ??
(async (token) => {
if (!jwks) {
throw new Error("JWT verification is not configured.");
}
const { payload } = await jwtVerify(token, jwks, {
issuer: env.cognitoIssuer,
});
return payload;
});
return createMiddleware<AppBindings>(async (c, next) => {
const path = new URL(c.req.url).pathname;
if (isPublicRoute(c.req.method, path)) {
await next();
return;
}
if (env.devAuthBypass) {
try {
const user = await upsertUserFromIdentity(handle, {
@ -73,7 +101,7 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) {
c.set("user", user);
} catch (error) {
if (error instanceof IdentityConflictError) {
return c.json({ error: error.message }, 409);
return errorJson(c, 409, "CONFLICT", error.message);
}
throw error;
}
@ -81,37 +109,30 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) {
return;
}
const header = c.req.header("authorization");
if (!header?.startsWith("Bearer ")) {
return c.json({ error: "Missing or invalid Authorization header." }, 401);
const stage = cookieStage(env);
const idToken = sessionCookieValue(cookieEventFromHeader(c.req.header("cookie")), "id", stage);
if (!idToken) {
return errorJson(c, 401, "UNAUTHENTICATED", "Missing id token.");
}
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,
}));
payload = await verifyToken(idToken);
} catch {
return c.json({ error: "Invalid or expired token." }, 401);
return errorJson(c, 401, "UNAUTHENTICATED", "Invalid or expired token.");
}
if (!audienceMatches(payload, env.cognitoAudience)) {
return c.json({ error: "Token audience does not match this API." }, 401);
return errorJson(c, 401, "UNAUTHENTICATED", "Token audience does not match this API.");
}
const identity = identityFromPayload(payload);
if (!identity) {
return c.json(
{
error:
"Token is missing required identity claims. Use a Cognito ID token or an access token that includes email.",
},
return errorJson(
c,
401,
"UNAUTHENTICATED",
"Token is missing required identity claims. Use a Cognito ID token or an access token that includes email.",
);
}
@ -121,11 +142,11 @@ export function createAuthMiddleware(env: ApiEnv, handle: Db) {
cognitoSub: identity.sub,
email: identity.email,
name: identity.name,
role: roleFromClaims(payload as Record<string, unknown>, "viewer"),
role: roleFromClaims(payload as Record<string, unknown>),
});
} catch (error) {
if (error instanceof IdentityConflictError) {
return c.json({ error: error.message }, 409);
return errorJson(c, 409, "CONFLICT", error.message);
}
throw error;
}

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { cookieStage, safeReturnTo, signinErrorLocation } from "./oauth.js";
describe("oauth helpers", () => {
it("honors a relative returnTo and rejects open redirects", () => {
expect(safeReturnTo("/invoices")).toBe("/invoices");
expect(safeReturnTo("//evil.com")).toBe("/");
expect(safeReturnTo("/login")).toBe("/");
expect(safeReturnTo("https://evil.com")).toBe("/");
expect(safeReturnTo("/invoices?tab=2#top")).toBe("/invoices?tab=2#top");
expect(signinErrorLocation("/invoices")).toBe("/login?error=1&returnTo=%2Finvoices");
expect(signinErrorLocation("/")).toBe("/login?error=1");
});
it("uses local cookie names for development and test", () => {
expect(cookieStage({ nodeEnv: "test", stage: "dev" })).toBe("local");
expect(cookieStage({ nodeEnv: "development", stage: "dev" })).toBe("local");
expect(cookieStage({ nodeEnv: "production", stage: "dev" })).toBe("dev");
});
});

View file

@ -0,0 +1,229 @@
import type { Context } from "hono";
import type { ApiEnv } from "../env.js";
import { errorJson } from "../http.js";
import {
cookieEventFromHeader,
cookieNames,
clearCookie,
clearedSessionCookies,
readOauthCookie,
serializeOauthCookie,
sessionCookieValue,
tokenCookies,
type CookieEvent,
} from "./cookies.js";
import {
authorizeUrl,
callbackRedirectUri,
createCognitoTokenClient,
type CognitoTokenClient,
} from "./cognito.js";
import { createPkce, safeEqual } from "./pkce.js";
const LOCAL_ORIGINS = [
"http://127.0.0.1:3000",
"http://localhost:3000",
"http://127.0.0.1:8787",
"http://localhost:8787",
] as const;
export function cookieStage(env: Pick<ApiEnv, "nodeEnv" | "stage">): string {
if (env.nodeEnv === "development" || env.nodeEnv === "test" || env.stage === "local") {
return "local";
}
return env.stage;
}
export function isPublicRoute(method: string, path: string): boolean {
if (method === "GET" && path === "/api/health") return true;
if (method === "GET" && path === "/api/ready") return true;
if (method === "GET" && path === "/api/auth/login") return true;
if (method === "GET" && path === "/api/auth/callback") return true;
if (method === "POST" && path === "/api/auth/refresh") return true;
if (method === "POST" && path === "/api/auth/logout") return true;
return false;
}
export function isMutating(method: string): boolean {
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
}
export function allowedOrigins(env: Pick<ApiEnv, "appOrigin" | "nodeEnv" | "stage">): Set<string> {
const origins = new Set<string>();
if (cookieStage(env) === "local") {
for (const origin of LOCAL_ORIGINS) origins.add(origin);
}
const app = env.appOrigin.replace(/\/$/, "");
if (app) origins.add(app);
return origins;
}
export function csrfAllowed(
c: Context,
env: Pick<ApiEnv, "appOrigin" | "nodeEnv" | "stage">,
): boolean {
const origin = c.req.header("origin")?.trim();
if (!origin) return false;
return allowedOrigins(env).has(origin);
}
const RETURN_TO_BASE = "https://return-to.invalid";
function hasControlCharacter(value: string): boolean {
for (const ch of value) {
const code = ch.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) return true;
}
return false;
}
function isPlainAfterDecoding(value: string): boolean {
let current = value;
for (let i = 0; i < 2; i += 1) {
let decoded: string;
try {
decoded = decodeURIComponent(current);
} catch {
return false;
}
if (decoded.includes("\\") || hasControlCharacter(decoded)) return false;
if (decoded === current) break;
current = decoded;
}
return true;
}
export function safeReturnTo(raw: string | null | undefined): string {
if (!raw) return "/";
const value = raw.trim();
if (!value.startsWith("/")) return "/";
if (value.includes("\\") || hasControlCharacter(value)) return "/";
if (!isPlainAfterDecoding(value)) return "/";
let url: URL;
try {
url = new URL(value, RETURN_TO_BASE);
} catch {
return "/";
}
if (url.origin !== RETURN_TO_BASE) return "/";
if (url.pathname === "/login") return "/";
const resolved = `${url.pathname}${url.search}${url.hash}`;
if (!resolved.startsWith("/") || resolved.startsWith("//")) return "/";
return resolved;
}
export function signinErrorLocation(returnTo?: string | null): string {
const safe = safeReturnTo(returnTo);
if (safe === "/") return "/login?error=1";
return `/login?error=1&returnTo=${encodeURIComponent(safe)}`;
}
export function applyCookies(c: Context, cookies: string[]): void {
for (const cookie of cookies) {
c.header("set-cookie", cookie, { append: true });
}
}
function cookieEvent(c: Context): CookieEvent {
return cookieEventFromHeader(c.req.header("cookie"));
}
export async function handleLogin(c: Context, env: ApiEnv) {
if (!env.cognitoAudience || !env.cognitoDomain) {
return errorJson(c, 500, "INTERNAL_ERROR", "Cognito is not configured.");
}
const returnTo = safeReturnTo(c.req.query("returnTo"));
const pkce = createPkce();
const location = authorizeUrl({
domain: env.cognitoDomain,
clientId: env.cognitoAudience,
redirectUri: callbackRedirectUri(env.appOrigin),
state: pkce.state,
challenge: pkce.challenge,
});
const stage = cookieStage(env);
applyCookies(c, [
serializeOauthCookie({ state: pkce.state, verifier: pkce.verifier, returnTo }, stage),
]);
return c.redirect(location, 302);
}
export async function handleCallback(
c: Context,
env: ApiEnv,
tokens: CognitoTokenClient = createCognitoTokenClient(env),
) {
const stage = cookieStage(env);
const oauth = readOauthCookie(cookieEvent(c), stage);
const fail = () => {
applyCookies(c, [clearCookie(cookieNames(stage).oauth, stage)]);
return c.redirect(signinErrorLocation(oauth?.returnTo), 302);
};
if (c.req.query("error")) return fail();
const code = c.req.query("code")?.trim();
const state = c.req.query("state")?.trim();
if (!code || !state || !oauth || !safeEqual(state, oauth.state)) return fail();
try {
const exchanged = await tokens.exchangeCode({
code,
verifier: oauth.verifier,
redirectUri: callbackRedirectUri(env.appOrigin),
});
if (!exchanged.refreshToken) return fail();
applyCookies(c, tokenCookies(exchanged, stage));
return c.redirect(safeReturnTo(oauth.returnTo), 302);
} catch {
return fail();
}
}
export async function handleRefresh(
c: Context,
env: ApiEnv,
tokens: CognitoTokenClient = createCognitoTokenClient(env),
) {
const stage = cookieStage(env);
const refreshToken = sessionCookieValue(cookieEvent(c), "refresh", stage);
if (!refreshToken) {
applyCookies(c, clearedSessionCookies(stage));
return errorJson(c, 401, "UNAUTHENTICATED", "Missing refresh token.");
}
try {
const rotated = await tokens.refresh(refreshToken);
applyCookies(
c,
tokenCookies(
{
accessToken: rotated.accessToken,
idToken: rotated.idToken,
refreshToken: rotated.refreshToken ?? refreshToken,
},
stage,
),
);
return c.body(null, 204);
} catch {
applyCookies(c, clearedSessionCookies(stage));
return errorJson(c, 401, "UNAUTHENTICATED", "Refresh failed.");
}
}
export async function handleLogout(
c: Context,
env: ApiEnv,
tokens: CognitoTokenClient = createCognitoTokenClient(env),
) {
const stage = cookieStage(env);
const refreshToken = sessionCookieValue(cookieEvent(c), "refresh", stage);
if (refreshToken && env.cognitoDomain && env.cognitoAudience) {
try {
await tokens.revoke(refreshToken);
} catch {
// Still clear cookies so the browser session ends.
}
}
applyCookies(c, clearedSessionCookies(stage));
return c.body(null, 204);
}

View file

@ -0,0 +1,18 @@
import { timingSafeEqual } from "node:crypto";
import type { Context } from "hono";
export const ORIGIN_VERIFY_HEADER = "x-origin-verify";
export function originVerifyHeader(c: Context): string {
return c.req.header(ORIGIN_VERIFY_HEADER)?.trim() ?? "";
}
/** When a secret is configured, only CloudFront's origin header is accepted. */
export function cloudFrontOriginAllowed(c: Context, secret: string | undefined): boolean {
if (!secret) return true;
const provided = originVerifyHeader(c);
const a = Buffer.from(provided);
const b = Buffer.from(secret);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}

View file

@ -0,0 +1,23 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
const STATE_BYTES = 32;
const VERIFIER_BYTES = 32;
export function randomToken(bytes = STATE_BYTES): string {
return randomBytes(bytes).toString("base64url");
}
export function createPkce(): { verifier: string; challenge: string; state: string } {
const verifier = randomToken(VERIFIER_BYTES);
return { verifier, challenge: pkceChallenge(verifier), state: randomToken() };
}
export function pkceChallenge(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
export function safeEqual(left: string, right: string): boolean {
const hashedLeft = createHash("sha256").update(left).digest();
const hashedRight = createHash("sha256").update(right).digest();
return timingSafeEqual(hashedLeft, hashedRight) && left.length === right.length;
}

View file

@ -5,7 +5,7 @@ import { IdentityConflictError, upsertUserFromIdentity } from "./upsert-user.js"
function createDb(options: {
bySub?: Record<string, unknown> | null;
byEmail?: Record<string, unknown> | null;
}): Db {
}): { handle: Db; setSpy: ReturnType<typeof vi.fn> } {
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) {
@ -22,17 +22,22 @@ function createDb(options: {
role: "admin" as const,
};
const setSpy = vi.fn((patch: Record<string, unknown>) => ({
where: vi.fn(() => ({
returning: vi.fn(async () => [
{ ...returningRow, ...patch, role: patch.role ?? returningRow.role },
]),
})),
}));
return {
handle: {
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]),
})),
})),
set: setSpy,
})),
insert: vi.fn(() => ({
values: vi.fn(() => ({
@ -40,12 +45,14 @@ function createDb(options: {
})),
})),
} as never,
},
setSpy,
};
}
describe("upsertUserFromIdentity", () => {
it("updates an existing row matched by cognito sub", async () => {
const handle = createDb({
const { handle } = createDb({
bySub: {
id: "11111111-1111-4111-8111-111111111111",
cognitoSub: "seed-sub-admin",
@ -66,8 +73,50 @@ describe("upsertUserFromIdentity", () => {
expect(handle.db.update).toHaveBeenCalled();
});
it("preserves the stored role when the token omits a role claim", async () => {
const { handle, setSpy } = createDb({
bySub: {
id: "11111111-1111-4111-8111-111111111111",
cognitoSub: "seed-sub-admin",
email: "admin@seahavenind.com",
name: "Dev Admin",
role: "admin",
},
});
const user = await upsertUserFromIdentity(handle, {
cognitoSub: "seed-sub-admin",
email: "admin@seahavenind.com",
name: "Dev Admin",
});
expect(setSpy).toHaveBeenCalledWith(expect.not.objectContaining({ role: expect.anything() }));
expect(user.role).toBe("admin");
});
it("writes an explicit role claim over the stored role", async () => {
const { handle, setSpy } = createDb({
bySub: {
id: "11111111-1111-4111-8111-111111111111",
cognitoSub: "seed-sub-admin",
email: "admin@seahavenind.com",
name: "Dev Admin",
role: "viewer",
},
});
await upsertUserFromIdentity(handle, {
cognitoSub: "seed-sub-admin",
email: "admin@seahavenind.com",
name: "Dev Admin",
role: "approver",
});
expect(setSpy).toHaveBeenCalledWith(expect.objectContaining({ role: "approver" }));
});
it("refuses to rebind an email owned by a different cognito sub", async () => {
const handle = createDb({
const { handle } = createDb({
bySub: null,
byEmail: {
id: "11111111-1111-4111-8111-111111111111",

View file

@ -7,7 +7,8 @@ export type AuthIdentity = {
cognitoSub: string;
email: string;
name: string;
role: UserRole;
/** Present only when the token carries an explicit role claim. */
role?: UserRole;
};
export type AuthUser = {
@ -46,6 +47,7 @@ function toAuthUser(row: {
/**
* Upsert by Cognito subject only. Never rebind an existing email to a new
* subject — that would allow account takeover if email claims collide.
* Missing role claims leave the stored role unchanged.
*/
export async function upsertUserFromIdentity(
handle: Db,
@ -56,14 +58,22 @@ export async function upsertUserFromIdentity(
});
if (bySub) {
const [updated] = await handle.db
.update(users)
.set({
const patch: {
email: string;
name: string;
role?: UserRole;
updatedAt: Date;
} = {
email: identity.email,
name: identity.name,
role: identity.role,
updatedAt: new Date(),
})
};
if (identity.role !== undefined) {
patch.role = identity.role;
}
const [updated] = await handle.db
.update(users)
.set(patch)
.where(eq(users.id, bySub.id))
.returning();
return toAuthUser(updated);
@ -83,7 +93,7 @@ export async function upsertUserFromIdentity(
cognitoSub: identity.cognitoSub,
email: identity.email,
name: identity.name,
role: identity.role,
role: identity.role ?? "viewer",
})
.returning();

View file

@ -0,0 +1,9 @@
// Build-time constant. The API image Dockerfile overwrites the compiled
// `build-info.js` with an inlined GIT_SHA after `tsc`. Keep the exact
// expression `process.env.GIT_SHA` here so local `tsx` still reads the env.
//
// Consumers must prefer BUILD_GIT_SHA over a runtime GIT_SHA env var. A
// deployed task can carry a stale env var from an earlier infra apply while
// its image has since been updated by deploy-api.yaml.
export const BUILD_GIT_SHA: string = process.env.GIT_SHA?.trim() || "";

View file

@ -10,6 +10,17 @@ export type PostgresDb = ReturnType<typeof createPostgresDb>;
export type DataApiDb = ReturnType<typeof createDataApiDb>;
export type Db = PostgresDb | DataApiDb;
type PostgresTx = Parameters<Parameters<PostgresDb["db"]["transaction"]>[0]>[0];
type DataApiTx = Parameters<Parameters<DataApiDb["db"]["transaction"]>[0]>[0];
/**
* Root connection or a transaction-scoped handle. Query helpers accept this so
* callers can pass `{ db: tx }` without casting a transaction to Db.
*/
export type DbHandle = {
db: Db["db"] | PostgresTx | DataApiTx;
};
function createPostgresDb(env: ApiEnv) {
const pool = new pg.Pool({ connectionString: env.databaseUrl });
return {

View file

@ -2,15 +2,12 @@ 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";
import { loadEnv, withLocalDevAuthBypass } 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",
});
const env = loadEnv(withLocalDevAuthBypass());
if (env.databaseDriver !== "postgres") {
throw new Error("db:migrate currently supports DATABASE_DRIVER=postgres only.");

View file

@ -0,0 +1,77 @@
import {
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { ApiEnv } from "./env.js";
export type PresignPutResult = {
url: string;
headers: Record<string, string>;
};
export type DocumentsStore = {
presignPut(input: { objectKey: string; contentType: string }): Promise<PresignPutResult>;
objectExists(objectKey: string): Promise<boolean>;
presignGet(objectKey: string): Promise<string>;
};
const SIGN_EXPIRES_SECONDS = 900;
export function createDocumentsStore(env: ApiEnv): DocumentsStore {
const client = new S3Client({
region: env.awsRegion,
endpoint: env.documentsEndpoint || undefined,
forcePathStyle: Boolean(env.documentsEndpoint),
credentials:
env.documentsAccessKey && env.documentsSecretKey
? { accessKeyId: env.documentsAccessKey, secretAccessKey: env.documentsSecretKey }
: undefined,
});
const bucket = env.documentsBucket;
return {
async presignPut({ objectKey, contentType }) {
const url = await getSignedUrl(
client,
new PutObjectCommand({ Bucket: bucket, Key: objectKey, ContentType: contentType }),
{ expiresIn: SIGN_EXPIRES_SECONDS },
);
return { url, headers: { "Content-Type": contentType } };
},
async objectExists(objectKey) {
try {
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey }));
return true;
} catch {
return false;
}
},
async presignGet(objectKey) {
return getSignedUrl(client, new GetObjectCommand({ Bucket: bucket, Key: objectKey }), {
expiresIn: SIGN_EXPIRES_SECONDS,
});
},
};
}
export function createMemoryDocumentsStore(): DocumentsStore & { uploaded: Set<string> } {
const uploaded = new Set<string>();
return {
uploaded,
async presignPut({ objectKey, contentType }) {
return {
url: `http://127.0.0.1:9000/seahaven-ap-documents/${objectKey}?presign=put`,
headers: { "Content-Type": contentType },
};
},
async objectExists(objectKey) {
return uploaded.has(objectKey);
},
async presignGet(objectKey) {
return `http://127.0.0.1:9000/seahaven-ap-documents/${objectKey}?presign=get`;
},
};
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { loadEnv } from "./env.js";
import { loadEnv, withLocalDevAuthBypass } from "./env.js";
describe("loadEnv", () => {
it("allows DEV_AUTH_BYPASS in development", () => {
@ -11,14 +11,17 @@ describe("loadEnv", () => {
expect(env.devAuthBypass).toBe(true);
expect(env.devAuthRole).toBe("viewer");
expect(env.port).toBe(8787);
expect(env.stage).toBe("local");
expect(env.sha).toBe("unknown");
});
it("allows DEV_AUTH_BYPASS in test", () => {
it("uses STAGE when provided", () => {
const env = loadEnv({
NODE_ENV: "test",
DEV_AUTH_BYPASS: "true",
STAGE: "dev",
});
expect(env.devAuthBypass).toBe(true);
expect(env.stage).toBe("dev");
});
it("rejects DEV_AUTH_BYPASS in production", () => {
@ -46,6 +49,18 @@ describe("loadEnv", () => {
).toThrow(/DEV_AUTH_BYPASS/);
});
it("defaults DEV_AUTH_BYPASS only for local node envs", () => {
expect(withLocalDevAuthBypass({ NODE_ENV: "development" }).DEV_AUTH_BYPASS).toBe("true");
expect(withLocalDevAuthBypass({ NODE_ENV: "test" }).DEV_AUTH_BYPASS).toBe("true");
expect(withLocalDevAuthBypass({ NODE_ENV: "production" }).DEV_AUTH_BYPASS).toBeUndefined();
expect(
withLocalDevAuthBypass({ NODE_ENV: "production", DEV_AUTH_BYPASS: "false" }).DEV_AUTH_BYPASS,
).toBe("false");
expect(() => loadEnv(withLocalDevAuthBypass({ NODE_ENV: "production" }))).toThrow(
/COGNITO_ISSUER/,
);
});
it("requires Cognito config when bypass is off", () => {
expect(() =>
loadEnv({

View file

@ -1,3 +1,5 @@
import { BUILD_GIT_SHA } from "./build-info.js";
export const USER_ROLES = ["admin", "ap_processor", "approver", "viewer"] as const;
export type UserRole = (typeof USER_ROLES)[number];
@ -6,8 +8,19 @@ export function isUserRole(value: string): value is UserRole {
return (USER_ROLES as readonly string[]).includes(value);
}
const LOCAL_NODE_ENVS = new Set(["development", "test"]);
export function withLocalDevAuthBypass(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
if (env.DEV_AUTH_BYPASS !== undefined) return env;
const nodeEnv = env.NODE_ENV ?? "development";
if (!LOCAL_NODE_ENVS.has(nodeEnv)) return env;
return { ...env, DEV_AUTH_BYPASS: "true" };
}
export type ApiEnv = {
nodeEnv: string;
stage: string;
sha: string;
port: number;
databaseDriver: "postgres" | "data-api";
databaseUrl: string;
@ -17,6 +30,13 @@ export type ApiEnv = {
rdsDatabase: string;
cognitoIssuer: string;
cognitoAudience: string;
cognitoDomain: string;
appOrigin: string;
originVerifySecret: string;
documentsEndpoint: string;
documentsAccessKey: string;
documentsSecretKey: string;
documentsBucket: string;
devAuthBypass: boolean;
devAuthSub: string;
devAuthEmail: string;
@ -39,8 +59,7 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
}
const devAuthBypass = env.DEV_AUTH_BYPASS === "true";
const localNodeEnvs = new Set(["development", "test"]);
if (devAuthBypass && !localNodeEnvs.has(nodeEnv)) {
if (devAuthBypass && !LOCAL_NODE_ENVS.has(nodeEnv)) {
throw new Error("DEV_AUTH_BYPASS is only allowed when NODE_ENV is development or test.");
}
@ -49,8 +68,16 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
throw new Error(`Invalid DEV_AUTH_ROLE: ${rawRole}`);
}
const local = LOCAL_NODE_ENVS.has(nodeEnv);
const stage = env.STAGE?.trim() || (local ? "local" : "dev");
// SHA is inlined at image build. Runtime GIT_SHA is a local-dev convenience
// only and must not be the deployed source of truth.
const sha = BUILD_GIT_SHA || env.GIT_SHA?.trim() || "unknown";
const base: ApiEnv = {
nodeEnv,
stage,
sha,
port: Number(env.API_PORT ?? "8787"),
databaseDriver,
databaseUrl: env.DATABASE_URL ?? "postgresql://seahaven:seahaven@127.0.0.1:5432/seahaven_ap",
@ -60,6 +87,14 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
rdsDatabase: env.RDS_DATABASE ?? "seahaven_ap",
cognitoIssuer: env.COGNITO_ISSUER ?? "",
cognitoAudience: env.COGNITO_AUDIENCE ?? "",
cognitoDomain: env.COGNITO_DOMAIN?.trim() ?? "",
appOrigin: env.APP_ORIGIN?.trim() || (local ? "http://127.0.0.1:3000" : ""),
originVerifySecret: env.ORIGIN_VERIFY_SECRET?.trim() ?? "",
documentsEndpoint: env.DOCUMENTS_ENDPOINT?.trim() || env.MINIO_ENDPOINT?.trim() || "",
documentsAccessKey: env.DOCUMENTS_ACCESS_KEY?.trim() || env.MINIO_ACCESS_KEY?.trim() || "",
documentsSecretKey: env.DOCUMENTS_SECRET_KEY?.trim() || env.MINIO_SECRET_KEY?.trim() || "",
documentsBucket:
env.DOCUMENTS_BUCKET?.trim() || env.MINIO_BUCKET?.trim() || "seahaven-ap-documents",
devAuthBypass,
devAuthSub: env.DEV_AUTH_SUB ?? "seed-sub-admin",
devAuthEmail: env.DEV_AUTH_EMAIL ?? "admin@seahavenind.com",

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

@ -0,0 +1,57 @@
import { randomUUID } from "node:crypto";
import type { Context } from "hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
export type ErrorCode =
| "UNAUTHENTICATED"
| "FORBIDDEN"
| "NOT_FOUND"
| "VALIDATION_ERROR"
| "CONFLICT"
| "INTERNAL_ERROR"
| "DATABASE_UNAVAILABLE";
export class ApiError extends Error {
constructor(
readonly status: ContentfulStatusCode,
readonly code: ErrorCode,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
export const CORRELATION_HEADER = "x-correlation-id";
export type ErrorEnvelope = {
error: {
code: ErrorCode;
message: string;
correlationId: string;
};
};
export function correlationIdFrom(c: Context): string {
const raw = c.req.header(CORRELATION_HEADER)?.trim();
if (raw && raw.length <= 128) {
return raw;
}
return randomUUID();
}
export function errorBody(code: ErrorCode, message: string, correlationId: string): ErrorEnvelope {
return { error: { code, message, correlationId } };
}
export function errorJson(
c: Context,
status: ContentfulStatusCode,
code: ErrorCode,
message: string,
correlationId?: string,
) {
const id = correlationId ?? correlationIdFrom(c);
c.header(CORRELATION_HEADER, id);
return c.json(errorBody(code, message, id), status);
}

View file

@ -8,8 +8,8 @@ async function main(): Promise<void> {
const handle = createDb(env);
const app = createApp(env, handle);
const server = serve({ fetch: app.fetch, port: env.port }, (info) => {
console.log(`@seahaven-ap/api listening on http://127.0.0.1:${info.port}`);
const server = serve({ fetch: app.fetch, hostname: "0.0.0.0", port: env.port }, (info) => {
console.log(`@seahaven-ap/api listening on http://0.0.0.0:${info.port}`);
});
const shutdown = async () => {

View file

@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import { createApp } from "../app.js";
import { createMemoryDocumentsStore } from "../documents.js";
import { loadEnv } from "../env.js";
import type { ErrorEnvelope } from "../http.js";
import { createFakeDb, emptyStore, SEED } from "../test/fake-db.js";
function expectEnvelope(body: unknown, code: string) {
const envelope = body as ErrorEnvelope;
expect(envelope.error.code).toBe(code);
}
function envFor(role: "admin" | "approver" | "viewer") {
return loadEnv({
NODE_ENV: "test",
DEV_AUTH_BYPASS: "true",
DEV_AUTH_SUB: SEED.user.cognitoSub,
DEV_AUTH_EMAIL: SEED.user.email,
DEV_AUTH_NAME: SEED.user.name,
DEV_AUTH_ROLE: role,
});
}
const jsonHeaders = {
"content-type": "application/json",
origin: "http://127.0.0.1:3000",
};
function app(role: "admin" | "approver" | "viewer" = "admin") {
return createApp(envFor(role), createFakeDb(), { documents: createMemoryDocumentsStore() });
}
describe("approval stubs", () => {
it("skips a step when the invoice is below skipBelowAmount", async () => {
const api = app();
const response = await api.request("/api/invoices", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({
vendorId: SEED.vendor.id,
invoiceNumber: "INV-SKIP",
amount: "10.00",
dueDate: "2026-10-01",
}),
});
expect(response.status).toBe(201);
const invoice = (await response.json()) as { id: string; status: string };
expect(invoice.status).toBe("approved");
const activity = await api.request(`/api/invoices/${invoice.id}/activity-logs`);
expect(activity.status).toBe(200);
const log = (await activity.json()) as { items: Array<{ message: string }> };
expect(log.items.some((item) => item.message.includes("below"))).toBe(true);
});
it("approves a pending step and writes activity", async () => {
const api = app("approver");
const response = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ action: "approve" }),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ status: "approved" });
const activity = await api.request(`/api/invoices/${SEED.invoice.id}/activity-logs`);
const log = (await activity.json()) as { items: Array<{ message: string }> };
expect(log.items.some((item) => item.message === "Step approved.")).toBe(true);
});
it("returns 403 when a viewer acts on a step", async () => {
const api = app("viewer");
const response = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ action: "approve" }),
});
expect(response.status).toBe(403);
expectEnvelope(await response.json(), "FORBIDDEN");
});
it("returns 403 when the caller is not the assignee for the step", async () => {
const store = emptyStore();
store.approvalSteps[0] = {
...SEED.step,
assigneeUserId: "22222222-2222-4222-8222-222222222222",
};
const api = createApp(envFor("admin"), createFakeDb(store), {
documents: createMemoryDocumentsStore(),
});
const response = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ action: "approve" }),
});
expect(response.status).toBe(403);
expectEnvelope(await response.json(), "FORBIDDEN");
expect(store.approvalSteps[0]?.status).toBe("pending");
});
it("voids an invoice, closes pending steps, and rejects later decisions", async () => {
const store = emptyStore();
const api = createApp(envFor("admin"), createFakeDb(store), {
documents: createMemoryDocumentsStore(),
});
const voided = await api.request(`/api/invoices/${SEED.invoice.id}`, {
method: "PATCH",
headers: jsonHeaders,
body: JSON.stringify({ status: "void" }),
});
expect(voided.status).toBe(200);
await expect(voided.json()).resolves.toMatchObject({ status: "void" });
expect(store.approvalSteps[0]?.status).toBe("skipped");
const inbox = await api.request("/api/inbox");
expect(inbox.status).toBe(200);
const listed = (await inbox.json()) as { items: Array<{ id: string }> };
expect(listed.items.some((item) => item.id === SEED.step.id)).toBe(false);
// Re-open a pending step to prove voided invoices stay sealed even if a step remains.
store.approvalSteps[0] = { ...store.approvalSteps[0]!, status: "pending" };
const decision = await api.request(`/api/approval-steps/${SEED.step.id}/decisions`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ action: "approve" }),
});
expect(decision.status).toBe(409);
expectEnvelope(await decision.json(), "CONFLICT");
expect(store.invoices[0]?.status).toBe("void");
});
it("lists the caller inbox and accepts a comment", async () => {
const api = app("admin");
const inbox = await api.request("/api/inbox");
expect(inbox.status).toBe(200);
const listed = (await inbox.json()) as { items: Array<{ id: string }> };
expect(listed.items[0]?.id).toBe(SEED.step.id);
const comment = await api.request(`/api/invoices/${SEED.invoice.id}/comments`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ body: "Looks good." }),
});
expect(comment.status).toBe(201);
await expect(comment.json()).resolves.toMatchObject({ body: "Looks good." });
});
it("creates an approval policy as admin", async () => {
const api = app("admin");
const response = await api.request("/api/approval-policies", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({
name: "High dollar",
priority: 1,
amountThreshold: "500.00",
skipBelowAmount: "0.00",
}),
});
expect(response.status).toBe(201);
await expect(response.json()).resolves.toMatchObject({ name: "High dollar", priority: 1 });
});
});

View file

@ -0,0 +1,293 @@
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import type { Db } from "../db/client.js";
import {
activityLog,
approvalPolicies,
approvalSteps,
invoiceComments,
invoices,
} from "../db/schema/index.js";
import type { AppBindings } from "../auth/middleware.js";
import { errorJson } from "../http.js";
import {
asMoney,
asString,
caller,
firstById,
iso,
isUuid,
parseJsonBody,
requireCan,
rowsOf,
} from "./helpers.js";
import { appendActivity, remainingPending } from "../approvals.js";
import type { UserRole } from "../env.js";
type PolicyRow = typeof approvalPolicies.$inferSelect;
type StepRow = typeof approvalSteps.$inferSelect;
type InvoiceRow = typeof invoices.$inferSelect;
type CommentRow = typeof invoiceComments.$inferSelect;
type ActivityRow = typeof activityLog.$inferSelect;
const DECISIONS = ["approve", "reject", "skip"] as const;
type Decision = (typeof DECISIONS)[number];
function isDecision(value: string): value is Decision {
return (DECISIONS as readonly string[]).includes(value);
}
function toPolicy(row: PolicyRow) {
return {
id: row.id,
name: row.name,
priority: row.priority,
amountThreshold: row.amountThreshold,
skipBelowAmount: row.skipBelowAmount,
active: row.active,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
function toStep(row: StepRow) {
return {
id: row.id,
invoiceId: row.invoiceId,
policyId: row.policyId,
stepOrder: row.stepOrder,
approverRole: row.approverRole,
assigneeUserId: row.assigneeUserId,
status: row.status,
actedByUserId: row.actedByUserId,
actedAt: row.actedAt ? iso(row.actedAt) : null,
createdAt: iso(row.createdAt),
};
}
function inboxVisible(step: StepRow, user: { id: string; role: UserRole }): boolean {
if (step.status !== "pending") return false;
if (step.assigneeUserId) return step.assigneeUserId === user.id;
return user.role === "admin" || user.role === step.approverRole;
}
export function createApprovalRoutes(handle: Db) {
const routes = new Hono<AppBindings>();
routes.get("/approval-policies", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const rows = await rowsOf<PolicyRow>(handle, approvalPolicies);
return c.json({ items: rows.map(toPolicy) });
});
routes.post("/approval-policies", async (c) => {
const denied = requireCan(c, "admin:settings");
if (denied) return denied;
const body = await parseJsonBody(c);
const name = asString(body.name).trim();
if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required.");
const [row] = await handle.db
.insert(approvalPolicies)
.values({
name,
priority: typeof body.priority === "number" ? body.priority : 100,
amountThreshold: asMoney(body.amountThreshold),
skipBelowAmount: asMoney(body.skipBelowAmount),
active: body.active === false ? false : true,
})
.returning();
return c.json(toPolicy(row), 201);
});
routes.get("/approval-policies/:id", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid policy id.");
const row = await firstById<PolicyRow>(handle, approvalPolicies, id);
if (!row) return errorJson(c, 404, "NOT_FOUND", "Policy not found.");
return c.json(toPolicy(row));
});
routes.patch("/approval-policies/:id", async (c) => {
const denied = requireCan(c, "admin:settings");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid policy id.");
const existing = await firstById<PolicyRow>(handle, approvalPolicies, id);
if (!existing) return errorJson(c, 404, "NOT_FOUND", "Policy not found.");
const body = await parseJsonBody(c);
const patch: Partial<typeof approvalPolicies.$inferInsert> & { id: string } = {
id,
updatedAt: new Date(),
};
if (body.name !== undefined) {
const name = asString(body.name).trim();
if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required.");
patch.name = name;
}
if (typeof body.priority === "number") patch.priority = body.priority;
if (body.amountThreshold !== undefined) patch.amountThreshold = asMoney(body.amountThreshold);
if (body.skipBelowAmount !== undefined) patch.skipBelowAmount = asMoney(body.skipBelowAmount);
if (typeof body.active === "boolean") patch.active = body.active;
const [row] = await handle.db
.update(approvalPolicies)
.set(patch)
.where(eq(approvalPolicies.id, id))
.returning();
return c.json(toPolicy(row));
});
routes.get("/inbox", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const user = caller(c);
const steps = await rowsOf<StepRow>(handle, approvalSteps);
const invoiceRows = await rowsOf<InvoiceRow>(handle, invoices);
const items = steps
.filter((step) => inboxVisible(step, user))
.map((step) => {
const invoice = invoiceRows.find((row) => row.id === step.invoiceId);
return {
...toStep(step),
invoiceNumber: invoice?.invoiceNumber ?? null,
amount: invoice?.amount ?? null,
vendorId: invoice?.vendorId ?? null,
};
});
return c.json({ items });
});
routes.post("/approval-steps/:id/decisions", async (c) => {
const denied = requireCan(c, "approve:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid step id.");
const step = await firstById<StepRow>(handle, approvalSteps, id);
if (!step) return errorJson(c, 404, "NOT_FOUND", "Approval step not found.");
if (step.status !== "pending") {
return errorJson(c, 409, "CONFLICT", "Step is not pending.");
}
const user = caller(c);
if (!inboxVisible(step, user)) {
return errorJson(c, 403, "FORBIDDEN", "You are not an assignee for this approval step.");
}
const invoice = await firstById<InvoiceRow>(handle, invoices, step.invoiceId);
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
if (invoice.status === "void") {
return errorJson(c, 409, "CONFLICT", "Cannot act on a voided invoice.");
}
const body = await parseJsonBody(c);
const action = asString(body.action);
if (!isDecision(action)) {
return errorJson(c, 400, "VALIDATION_ERROR", "action must be approve, reject, or skip.");
}
const nextStatus =
action === "approve" ? "approved" : action === "reject" ? "rejected" : "skipped";
const [updated] = await handle.db
.update(approvalSteps)
.set({
id,
status: nextStatus,
actedByUserId: user.id,
actedAt: new Date(),
})
.where(eq(approvalSteps.id, id))
.returning();
await appendActivity(
handle,
step.invoiceId,
user.id,
action === "approve"
? "Step approved."
: action === "reject"
? "Step rejected."
: "Step skipped.",
);
let invoiceStatus: InvoiceRow["status"] | undefined;
if (action === "reject") invoiceStatus = "rejected";
else if ((await remainingPending(handle, step.invoiceId)).length === 0) {
invoiceStatus = "approved";
}
if (invoiceStatus) {
await handle.db
.update(invoices)
.set({ id: step.invoiceId, status: invoiceStatus, updatedAt: new Date() })
.where(eq(invoices.id, step.invoiceId))
.returning();
}
return c.json(toStep(updated));
});
routes.get("/invoices/:id/comments", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
const invoice = await firstById<InvoiceRow>(handle, invoices, id);
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
const rows = (await rowsOf<CommentRow>(handle, invoiceComments)).filter(
(row) => row.invoiceId === id,
);
return c.json({
items: rows.map((row) => ({
id: row.id,
invoiceId: row.invoiceId,
authorUserId: row.authorUserId,
body: row.body,
createdAt: iso(row.createdAt),
})),
});
});
routes.post("/invoices/:id/comments", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
const invoice = await firstById<InvoiceRow>(handle, invoices, id);
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
const body = asString((await parseJsonBody(c)).body).trim();
if (!body) return errorJson(c, 400, "VALIDATION_ERROR", "body is required.");
const user = caller(c);
const [row] = await handle.db
.insert(invoiceComments)
.values({ invoiceId: id, authorUserId: user.id, body })
.returning();
await appendActivity(handle, id, user.id, "Comment added.");
return c.json(
{
id: row.id,
invoiceId: row.invoiceId,
authorUserId: row.authorUserId,
body: row.body,
createdAt: iso(row.createdAt),
},
201,
);
});
routes.get("/invoices/:id/activity-logs", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
const invoice = await firstById<InvoiceRow>(handle, invoices, id);
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
const rows = (await rowsOf<ActivityRow>(handle, activityLog)).filter(
(row) => row.invoiceId === id,
);
return c.json({
items: rows.map((row) => ({
id: row.id,
invoiceId: row.invoiceId,
actorUserId: row.actorUserId,
message: row.message,
createdAt: iso(row.createdAt),
})),
});
});
return routes;
}

View file

@ -0,0 +1,16 @@
import { Hono } from "hono";
import type { ApiEnv } from "../env.js";
import type { CognitoTokenClient } from "../auth/cognito.js";
import { handleCallback, handleLogin, handleLogout, handleRefresh } from "../auth/oauth.js";
import type { AppBindings } from "../auth/middleware.js";
export function createAuthRoutes(env: ApiEnv, deps: { tokens?: CognitoTokenClient } = {}) {
const routes = new Hono<AppBindings>();
routes.get("/auth/login", (c) => handleLogin(c, env));
routes.get("/auth/callback", (c) => handleCallback(c, env, deps.tokens));
routes.post("/auth/refresh", (c) => handleRefresh(c, env, deps.tokens));
routes.post("/auth/logout", (c) => handleLogout(c, env, deps.tokens));
return routes;
}

View file

@ -0,0 +1,80 @@
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import type { Db } from "../db/client.js";
import { departments } from "../db/schema/index.js";
import type { AppBindings } from "../auth/middleware.js";
import { errorJson } from "../http.js";
import { asString, iso, isUuid, parseJsonBody, requireCan } from "./helpers.js";
function toDepartment(row: typeof departments.$inferSelect) {
return {
id: row.id,
code: row.code,
name: row.name,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
export function createDepartmentRoutes(handle: Db) {
const routes = new Hono<AppBindings>();
routes.get("/departments", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const rows = await handle.db.select().from(departments);
return c.json({ items: rows.map(toDepartment) });
});
routes.get("/departments/:id", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid department id.");
const row = await handle.db.query.departments.findFirst({ where: eq(departments.id, id) });
if (!row) return errorJson(c, 404, "NOT_FOUND", "Department not found.");
return c.json(toDepartment(row));
});
routes.post("/departments", async (c) => {
const denied = requireCan(c, "admin:settings");
if (denied) return denied;
const body = await parseJsonBody(c);
const code = asString(body.code).trim();
const name = asString(body.name).trim();
if (!code || !name) return errorJson(c, 400, "VALIDATION_ERROR", "Code and name are required.");
const [row] = await handle.db.insert(departments).values({ code, name }).returning();
return c.json(toDepartment(row), 201);
});
routes.patch("/departments/:id", async (c) => {
const denied = requireCan(c, "admin:settings");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid department id.");
const existing = await handle.db.query.departments.findFirst({
where: eq(departments.id, id),
});
if (!existing) return errorJson(c, 404, "NOT_FOUND", "Department not found.");
const body = await parseJsonBody(c);
const patch: Partial<typeof departments.$inferInsert> = { updatedAt: new Date() };
if (body.code !== undefined) {
const code = asString(body.code).trim();
if (!code) return errorJson(c, 400, "VALIDATION_ERROR", "Code is required.");
patch.code = code;
}
if (body.name !== undefined) {
const name = asString(body.name).trim();
if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required.");
patch.name = name;
}
const [row] = await handle.db
.update(departments)
.set(patch)
.where(eq(departments.id, id))
.returning();
return c.json(toDepartment(row));
});
return routes;
}

View file

@ -0,0 +1,78 @@
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import type { Db } from "../db/client.js";
import { glAccounts } from "../db/schema/index.js";
import type { AppBindings } from "../auth/middleware.js";
import { errorJson } from "../http.js";
import { asString, iso, isUuid, parseJsonBody, requireCan } from "./helpers.js";
function toGlAccount(row: typeof glAccounts.$inferSelect) {
return {
id: row.id,
code: row.code,
name: row.name,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
export function createGlAccountRoutes(handle: Db) {
const routes = new Hono<AppBindings>();
routes.get("/gl-accounts", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const rows = await handle.db.select().from(glAccounts);
return c.json({ items: rows.map(toGlAccount) });
});
routes.get("/gl-accounts/:id", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid GL account id.");
const row = await handle.db.query.glAccounts.findFirst({ where: eq(glAccounts.id, id) });
if (!row) return errorJson(c, 404, "NOT_FOUND", "GL account not found.");
return c.json(toGlAccount(row));
});
routes.post("/gl-accounts", async (c) => {
const denied = requireCan(c, "admin:settings");
if (denied) return denied;
const body = await parseJsonBody(c);
const code = asString(body.code).trim();
const name = asString(body.name).trim();
if (!code || !name) return errorJson(c, 400, "VALIDATION_ERROR", "Code and name are required.");
const [row] = await handle.db.insert(glAccounts).values({ code, name }).returning();
return c.json(toGlAccount(row), 201);
});
routes.patch("/gl-accounts/:id", async (c) => {
const denied = requireCan(c, "admin:settings");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid GL account id.");
const existing = await handle.db.query.glAccounts.findFirst({ where: eq(glAccounts.id, id) });
if (!existing) return errorJson(c, 404, "NOT_FOUND", "GL account not found.");
const body = await parseJsonBody(c);
const patch: Partial<typeof glAccounts.$inferInsert> = { updatedAt: new Date() };
if (body.code !== undefined) {
const code = asString(body.code).trim();
if (!code) return errorJson(c, 400, "VALIDATION_ERROR", "Code is required.");
patch.code = code;
}
if (body.name !== undefined) {
const name = asString(body.name).trim();
if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required.");
patch.name = name;
}
const [row] = await handle.db
.update(glAccounts)
.set(patch)
.where(eq(glAccounts.id, id))
.returning();
return c.json(toGlAccount(row));
});
return routes;
}

View file

@ -1,16 +1,26 @@
import { Hono } from "hono";
import type { ApiEnv } from "../env.js";
import type { Db } from "../db/client.js";
import { pingDb } from "../db/client.js";
import { CORRELATION_HEADER, correlationIdFrom, errorJson } from "../http.js";
export function createHealthRoutes(handle: Db) {
export function createHealthRoutes(env: ApiEnv, handle: Db) {
const routes = new Hono();
routes.get("/health", async (c) => {
routes.get("/health", (c) => {
const correlationId = correlationIdFrom(c);
c.header(CORRELATION_HEADER, correlationId);
return c.json({ stage: env.stage, sha: env.sha });
});
routes.get("/ready", async (c) => {
try {
await pingDb(handle);
const correlationId = correlationIdFrom(c);
c.header(CORRELATION_HEADER, correlationId);
return c.json({ status: "ok", database: "up" });
} catch {
return c.json({ error: "Database is unavailable." }, 503);
return errorJson(c, 503, "DATABASE_UNAVAILABLE", "Database is unavailable.");
}
});

View file

@ -0,0 +1,94 @@
import { randomUUID } from "node:crypto";
import type { Context } from "hono";
import type { DbHandle } from "../db/client.js";
import type { UserRole } from "../env.js";
import { can, type RbacAction } from "../auth/rbac.js";
import { ApiError, errorJson } from "../http.js";
import type { AppBindings } from "../auth/middleware.js";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function isUuid(value: string): boolean {
return UUID_RE.test(value);
}
export function requireCan(c: Context<AppBindings>, action: RbacAction) {
const user = c.get("user");
if (!can(user.role, action)) {
return errorJson(c, 403, "FORBIDDEN", `Role ${user.role} is not allowed to ${action}.`);
}
return null;
}
export function caller(c: Context<AppBindings>) {
return c.get("user");
}
export function iso(value: Date | string): string {
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
export function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
export function optionalString(value: unknown): string | null | undefined {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value === "string") return value;
return undefined;
}
export function newId(): string {
return randomUUID();
}
export async function parseJsonBody(c: Context): Promise<Record<string, unknown>> {
try {
return await c.req.json<Record<string, unknown>>();
} catch {
throw new ApiError(400, "VALIDATION_ERROR", "Request body must be valid JSON.");
}
}
export function asMoney(value: unknown): string | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value.toFixed(2);
}
if (typeof value === "string" && /^\d+(\.\d{1,2})?$/.test(value.trim())) {
return Number(value).toFixed(2);
}
return null;
}
export function moneyCents(value: string): number {
return Math.round(Number(value) * 100);
}
export function isDateOnly(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(value);
}
export async function rowsOf<T>(handle: DbHandle, table: unknown): Promise<T[]> {
return (await handle.db.select().from(table as never)) as T[];
}
export async function firstById<T extends { id: string }>(
handle: DbHandle,
table: unknown,
id: string,
): Promise<T | undefined> {
const rows = await rowsOf<T>(handle, table);
return rows.find((row) => row.id === id);
}
export function isUniqueViolation(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code: unknown }).code === "23505"
);
}
export type { UserRole };

View file

@ -0,0 +1,261 @@
import { getTableName } from "drizzle-orm";
import { describe, expect, it, vi } from "vitest";
import { createApp } from "../app.js";
import { createMemoryDocumentsStore } from "../documents.js";
import { loadEnv } from "../env.js";
import type { ErrorEnvelope } from "../http.js";
import { createFakeDb, emptyStore, SEED } from "../test/fake-db.js";
function expectEnvelope(body: unknown, code: string) {
const envelope = body as ErrorEnvelope;
expect(envelope.error.code).toBe(code);
}
function envFor(role: "admin" | "viewer") {
return loadEnv({
NODE_ENV: "test",
DEV_AUTH_BYPASS: "true",
DEV_AUTH_SUB: SEED.user.cognitoSub,
DEV_AUTH_EMAIL: SEED.user.email,
DEV_AUTH_NAME: SEED.user.name,
DEV_AUTH_ROLE: role,
});
}
const jsonHeaders = {
"content-type": "application/json",
origin: "http://127.0.0.1:3000",
};
describe("invoice stubs", () => {
it("lists the seeded invoice", async () => {
const app = createApp(envFor("admin"), createFakeDb(), {
documents: createMemoryDocumentsStore(),
});
const response = await app.request("/api/invoices");
expect(response.status).toBe(200);
const body = (await response.json()) as { items: Array<{ invoiceNumber: string }> };
expect(body.items[0]?.invoiceNumber).toBe("INV-1001");
});
it("creates an invoice when line amounts sum to the header amount", async () => {
const app = createApp(envFor("admin"), createFakeDb(), {
documents: createMemoryDocumentsStore(),
});
const response = await app.request("/api/invoices", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({
vendorId: SEED.vendor.id,
invoiceNumber: "INV-2002",
amount: "100.00",
dueDate: "2026-10-01",
lines: [
{ description: "Half", amount: "40.00" },
{ description: "Rest", amount: "60.00" },
],
}),
});
expect(response.status).toBe(201);
await expect(response.json()).resolves.toMatchObject({
invoiceNumber: "INV-2002",
amount: "100.00",
});
});
it("does not persist a partial invoice when a line insert fails", async () => {
const store = emptyStore();
const handle = createFakeDb(store);
const db = handle.db as { insert: ReturnType<typeof vi.fn> };
const originalInsert = db.insert.getMockImplementation() ?? db.insert;
db.insert.mockImplementation((table: unknown) => {
if (getTableName(table as never) === "invoice_lines") {
return {
values: () => ({
returning: async () => {
throw new Error("insert failed");
},
}),
};
}
return originalInsert(table);
});
const app = createApp(envFor("admin"), handle, {
documents: createMemoryDocumentsStore(),
});
const response = await app.request("/api/invoices", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({
vendorId: SEED.vendor.id,
invoiceNumber: "INV-2003",
amount: "100.00",
dueDate: "2026-10-01",
lines: [
{ description: "Half", amount: "40.00" },
{ description: "Rest", amount: "60.00" },
],
}),
});
expect(response.status).toBe(500);
expect(store.invoices.map((row) => row.invoiceNumber)).toEqual(["INV-1001"]);
expect(store.invoiceLines).toHaveLength(1);
expect(store.invoiceLines[0]?.id).toBe(SEED.line.id);
});
it("rejects a duplicate active vendor and invoice number", async () => {
const app = createApp(envFor("admin"), createFakeDb(), {
documents: createMemoryDocumentsStore(),
});
const response = await app.request("/api/invoices", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({
vendorId: SEED.vendor.id,
invoiceNumber: "INV-1001",
amount: "10.00",
dueDate: "2026-10-01",
}),
});
expect(response.status).toBe(409);
expectEnvelope(await response.json(), "CONFLICT");
});
it("rejects a line replace whose amounts do not sum to the invoice", async () => {
const app = createApp(envFor("admin"), createFakeDb(), {
documents: createMemoryDocumentsStore(),
});
const response = await app.request(`/api/invoices/${SEED.invoice.id}/lines`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify({
items: [{ description: "Too small", amount: "1.00" }],
}),
});
expect(response.status).toBe(400);
expectEnvelope(await response.json(), "VALIDATION_ERROR");
});
it("replaces lines when the amounts sum to the invoice", async () => {
const app = createApp(envFor("admin"), createFakeDb(), {
documents: createMemoryDocumentsStore(),
});
const response = await app.request(`/api/invoices/${SEED.invoice.id}/lines`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify({
items: [
{ description: "Labor", amount: "1000.00", glAccountId: SEED.gl.id },
{ description: "Parts", amount: "250.00", departmentId: SEED.department.id },
],
}),
});
expect(response.status).toBe(200);
const body = (await response.json()) as { items: Array<{ amount: string }> };
expect(body.items).toHaveLength(2);
});
it("rejects PATCH that sets approved without an approval decision", async () => {
const app = createApp(envFor("admin"), createFakeDb(), {
documents: createMemoryDocumentsStore(),
});
const response = await app.request(`/api/invoices/${SEED.invoice.id}`, {
method: "PATCH",
headers: jsonHeaders,
body: JSON.stringify({ status: "approved" }),
});
expect(response.status).toBe(400);
expectEnvelope(await response.json(), "VALIDATION_ERROR");
});
it("returns 400 VALIDATION_ERROR for a malformed JSON body", async () => {
const app = createApp(envFor("admin"), createFakeDb(), {
documents: createMemoryDocumentsStore(),
});
const response = await app.request(`/api/invoices/${SEED.invoice.id}`, {
method: "PATCH",
headers: jsonHeaders,
body: "{not-json",
});
expect(response.status).toBe(400);
const body = (await response.json()) as ErrorEnvelope;
expect(body.error.code).toBe("VALIDATION_ERROR");
expect(body.error.message).toBe("Request body must be valid JSON.");
});
it("keeps existing lines when a replace insert fails", async () => {
const store = emptyStore();
const handle = createFakeDb(store);
const db = handle.db as { insert: ReturnType<typeof vi.fn> };
db.insert.mockImplementation(() => ({
values: () => ({
returning: async () => {
throw new Error("insert failed");
},
}),
}));
const app = createApp(envFor("admin"), handle, {
documents: createMemoryDocumentsStore(),
});
const response = await app.request(`/api/invoices/${SEED.invoice.id}/lines`, {
method: "PUT",
headers: jsonHeaders,
body: JSON.stringify({
items: [
{ description: "Labor", amount: "1000.00" },
{ description: "Parts", amount: "250.00" },
],
}),
});
expect(response.status).toBe(500);
expect(store.invoiceLines).toHaveLength(1);
expect(store.invoiceLines[0]?.id).toBe(SEED.line.id);
});
it("presigns a document and confirms after upload", async () => {
const documents = createMemoryDocumentsStore();
const app = createApp(envFor("admin"), createFakeDb(), { documents });
const created = await app.request(`/api/invoices/${SEED.invoice.id}/documents`, {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({ fileName: "inv.pdf", contentType: "application/pdf" }),
});
expect(created.status).toBe(201);
const doc = (await created.json()) as { id: string; objectKey: string; uploadUrl: string };
expect(doc.uploadUrl).toContain("presign=put");
const missing = await app.request(`/api/documents/${doc.id}/confirmations`, {
method: "POST",
headers: jsonHeaders,
body: "{}",
});
expect(missing.status).toBe(409);
documents.uploaded.add(doc.objectKey);
const confirmed = await app.request(`/api/documents/${doc.id}/confirmations`, {
method: "POST",
headers: jsonHeaders,
body: "{}",
});
expect(confirmed.status).toBe(200);
const got = await app.request(`/api/documents/${doc.id}`);
expect(got.status).toBe(200);
await expect(got.json()).resolves.toMatchObject({ id: doc.id, fileName: "inv.pdf" });
});
it("returns 403 when a viewer writes an invoice", async () => {
const app = createApp(envFor("viewer"), createFakeDb(), {
documents: createMemoryDocumentsStore(),
});
const response = await app.request("/api/invoices", {
method: "POST",
headers: jsonHeaders,
body: JSON.stringify({
vendorId: SEED.vendor.id,
invoiceNumber: "INV-9",
amount: "1.00",
dueDate: "2026-10-01",
}),
});
expect(response.status).toBe(403);
expectEnvelope(await response.json(), "FORBIDDEN");
});
});

View file

@ -0,0 +1,463 @@
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import type { Db, DbHandle } from "../db/client.js";
import type { DocumentsStore } from "../documents.js";
import { documents, invoiceLines, invoices, vendors } from "../db/schema/index.js";
import type { AppBindings } from "../auth/middleware.js";
import { errorJson } from "../http.js";
import { applyMatchingPolicy, closePendingSteps } from "../approvals.js";
import {
asMoney,
asString,
caller,
firstById,
isDateOnly,
isUniqueViolation,
iso,
isUuid,
moneyCents,
newId,
optionalString,
parseJsonBody,
requireCan,
rowsOf,
} from "./helpers.js";
const PAYMENT_METHODS = ["check", "ach"] as const;
type InvoiceRow = typeof invoices.$inferSelect;
type LineRow = typeof invoiceLines.$inferSelect;
type DocumentRow = typeof documents.$inferSelect;
type VendorRow = typeof vendors.$inferSelect;
type LineInput = {
description: string;
amount: string;
glAccountId: string | null;
departmentId: string | null;
};
function isPaymentMethod(value: string): value is (typeof PAYMENT_METHODS)[number] {
return (PAYMENT_METHODS as readonly string[]).includes(value);
}
function toInvoice(row: InvoiceRow, lines: LineRow[]) {
return {
id: row.id,
vendorId: row.vendorId,
invoiceNumber: row.invoiceNumber,
amount: row.amount,
amountDue: row.amountDue,
dueDate: row.dueDate,
payDate: row.payDate,
sendPaymentOn: row.sendPaymentOn,
status: row.status,
paymentMethod: row.paymentMethod,
memo: row.memo,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
lines: lines.map(toLine),
};
}
function toLine(row: LineRow) {
return {
id: row.id,
invoiceId: row.invoiceId,
description: row.description,
amount: row.amount,
glAccountId: row.glAccountId,
departmentId: row.departmentId,
createdAt: iso(row.createdAt),
};
}
function toDocument(
row: DocumentRow,
urls: { uploadUrl?: string; uploadHeaders?: Record<string, string>; downloadUrl?: string } = {},
) {
return {
id: row.id,
invoiceId: row.invoiceId,
objectKey: row.objectKey,
contentType: row.contentType,
fileName: row.fileName,
uploadedByUserId: row.uploadedByUserId,
createdAt: iso(row.createdAt),
...urls,
};
}
function parseLine(body: Record<string, unknown>): LineInput | string {
const description = asString(body.description).trim();
const amount = asMoney(body.amount);
if (!description || !amount) return "Line description and amount are required.";
const glAccountId = optionalString(body.glAccountId) ?? null;
const departmentId = optionalString(body.departmentId) ?? null;
if (glAccountId && !isUuid(glAccountId)) return "Invalid glAccountId.";
if (departmentId && !isUuid(departmentId)) return "Invalid departmentId.";
return { description, amount, glAccountId, departmentId };
}
function linesSumToAmount(lines: Array<{ amount: string }>, amount: string): boolean {
const sum = lines.reduce((total, line) => total + moneyCents(line.amount), 0);
return sum === moneyCents(amount);
}
async function linesFor(handle: DbHandle, invoiceId: string): Promise<LineRow[]> {
const rows = await rowsOf<LineRow>(handle, invoiceLines);
return rows.filter((row) => row.invoiceId === invoiceId);
}
async function activeDuplicate(
handle: DbHandle,
vendorId: string,
invoiceNumber: string,
exceptId?: string,
): Promise<boolean> {
const rows = await rowsOf<InvoiceRow>(handle, invoices);
return rows.some(
(row) =>
row.vendorId === vendorId &&
row.invoiceNumber === invoiceNumber &&
row.status !== "void" &&
row.id !== exceptId,
);
}
function safeFileName(value: string): string {
return (
value
.replace(/[/\\]+/g, "_")
.replace(/^\.+/, "_")
.slice(0, 180) || "document"
);
}
export function createInvoiceRoutes(handle: Db, store: DocumentsStore) {
const routes = new Hono<AppBindings>();
routes.get("/invoices", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const rows = await rowsOf<InvoiceRow>(handle, invoices);
const allLines = await rowsOf<LineRow>(handle, invoiceLines);
return c.json({
items: rows.map((row) =>
toInvoice(
row,
allLines.filter((line) => line.invoiceId === row.id),
),
),
});
});
routes.get("/invoices/:id", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
const row = await firstById<InvoiceRow>(handle, invoices, id);
if (!row) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
return c.json(toInvoice(row, await linesFor(handle, id)));
});
routes.post("/invoices", async (c) => {
const denied = requireCan(c, "write:invoices");
if (denied) return denied;
const body = await parseJsonBody(c);
const vendorId = asString(body.vendorId);
const invoiceNumber = asString(body.invoiceNumber).trim();
const amount = asMoney(body.amount);
const dueDate = asString(body.dueDate);
if (!isUuid(vendorId) || !invoiceNumber || !amount || !isDateOnly(dueDate)) {
return errorJson(
c,
400,
"VALIDATION_ERROR",
"vendorId, invoiceNumber, amount, and dueDate are required.",
);
}
const vendor = await firstById<VendorRow>(handle, vendors, vendorId);
if (!vendor) return errorJson(c, 400, "VALIDATION_ERROR", "Vendor not found.");
const method = asString(body.paymentMethod, vendor.defaultPaymentMethod);
if (!isPaymentMethod(method)) {
return errorJson(c, 400, "VALIDATION_ERROR", "Invalid paymentMethod.");
}
const parsedLines: LineInput[] = [];
if (Array.isArray(body.lines)) {
for (const raw of body.lines) {
if (!raw || typeof raw !== "object") {
return errorJson(c, 400, "VALIDATION_ERROR", "Each line must be an object.");
}
const parsed = parseLine(raw as Record<string, unknown>);
if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed);
parsedLines.push(parsed);
}
if (!linesSumToAmount(parsedLines, amount)) {
return errorJson(
c,
400,
"VALIDATION_ERROR",
"Line amounts must sum to the invoice amount.",
);
}
}
if (await activeDuplicate(handle, vendorId, invoiceNumber)) {
return errorJson(
c,
409,
"CONFLICT",
"An active invoice already uses this vendor and invoice number.",
);
}
let latest: InvoiceRow | undefined;
let currentLines: LineRow[] = [];
try {
await handle.db.transaction(async (tx) => {
const scoped: DbHandle = { db: tx };
const [row] = await tx
.insert(invoices)
.values({
vendorId,
invoiceNumber,
amount,
amountDue: amount,
dueDate,
paymentMethod: method,
memo: asString(body.memo),
status: "pending_approval",
})
.returning();
for (const line of parsedLines) {
await tx
.insert(invoiceLines)
.values({ invoiceId: row.id, ...line })
.returning();
}
latest = await applyMatchingPolicy(scoped, row, caller(c).id);
currentLines = await linesFor(scoped, row.id);
});
} catch (error) {
if (isUniqueViolation(error)) {
return errorJson(
c,
409,
"CONFLICT",
"An active invoice already uses this vendor and invoice number.",
);
}
throw error;
}
if (!latest) {
return errorJson(c, 500, "INTERNAL_ERROR", "Invoice create did not complete.");
}
return c.json(toInvoice(latest, currentLines), 201);
});
routes.patch("/invoices/:id", async (c) => {
const denied = requireCan(c, "write:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
const existing = await firstById<InvoiceRow>(handle, invoices, id);
if (!existing) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
const body = await parseJsonBody(c);
const patch: Partial<typeof invoices.$inferInsert> = { id, updatedAt: new Date() };
if (body.vendorId !== undefined) {
const vendorId = asString(body.vendorId);
if (!isUuid(vendorId)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendorId.");
patch.vendorId = vendorId;
}
if (body.invoiceNumber !== undefined) {
const invoiceNumber = asString(body.invoiceNumber).trim();
if (!invoiceNumber)
return errorJson(c, 400, "VALIDATION_ERROR", "invoiceNumber is required.");
patch.invoiceNumber = invoiceNumber;
}
if (body.amount !== undefined) {
const amount = asMoney(body.amount);
if (!amount) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid amount.");
patch.amount = amount;
patch.amountDue = amount;
}
if (body.dueDate !== undefined) {
const dueDate = asString(body.dueDate);
if (!isDateOnly(dueDate)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid dueDate.");
patch.dueDate = dueDate;
}
if (body.payDate !== undefined) {
const payDate = optionalString(body.payDate) ?? null;
if (payDate && !isDateOnly(payDate))
return errorJson(c, 400, "VALIDATION_ERROR", "Invalid payDate.");
patch.payDate = payDate;
}
if (body.sendPaymentOn !== undefined) {
const sendPaymentOn = optionalString(body.sendPaymentOn) ?? null;
if (sendPaymentOn && !isDateOnly(sendPaymentOn)) {
return errorJson(c, 400, "VALIDATION_ERROR", "Invalid sendPaymentOn.");
}
patch.sendPaymentOn = sendPaymentOn;
}
if (body.status !== undefined) {
const status = asString(body.status);
if (status !== "void") {
return errorJson(
c,
400,
"VALIDATION_ERROR",
"PATCH may only set status to void. Approval and payment statuses go through decision routes.",
);
}
patch.status = "void";
}
if (body.paymentMethod !== undefined) {
const method = asString(body.paymentMethod);
if (!isPaymentMethod(method))
return errorJson(c, 400, "VALIDATION_ERROR", "Invalid paymentMethod.");
patch.paymentMethod = method;
}
if (body.memo !== undefined) patch.memo = asString(body.memo);
const nextVendor = patch.vendorId ?? existing.vendorId;
const nextNumber = patch.invoiceNumber ?? existing.invoiceNumber;
const nextStatus = patch.status ?? existing.status;
if (nextStatus !== "void" && (await activeDuplicate(handle, nextVendor, nextNumber, id))) {
return errorJson(
c,
409,
"CONFLICT",
"An active invoice already uses this vendor and invoice number.",
);
}
const nextAmount = patch.amount ?? existing.amount;
const currentLines = await linesFor(handle, id);
if (currentLines.length > 0 && !linesSumToAmount(currentLines, nextAmount)) {
return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount.");
}
let row: InvoiceRow;
try {
if (patch.status === "void") {
await closePendingSteps(handle, id, caller(c).id);
}
[row] = await handle.db.update(invoices).set(patch).where(eq(invoices.id, id)).returning();
} catch (error) {
if (isUniqueViolation(error)) {
return errorJson(
c,
409,
"CONFLICT",
"An active invoice already uses this vendor and invoice number.",
);
}
throw error;
}
return c.json(toInvoice(row, currentLines));
});
routes.post("/invoices/:id/lines", async (c) => {
const denied = requireCan(c, "write:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
const invoice = await firstById<InvoiceRow>(handle, invoices, id);
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
const parsed = parseLine(await parseJsonBody(c));
if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed);
const [row] = await handle.db
.insert(invoiceLines)
.values({ invoiceId: id, ...parsed })
.returning();
return c.json(toLine(row), 201);
});
routes.put("/invoices/:id/lines", async (c) => {
const denied = requireCan(c, "write:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
const invoice = await firstById<InvoiceRow>(handle, invoices, id);
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
const body = await parseJsonBody(c);
if (!Array.isArray(body.items)) {
return errorJson(c, 400, "VALIDATION_ERROR", "items must be an array of lines.");
}
const parsedLines: LineInput[] = [];
for (const raw of body.items) {
if (!raw || typeof raw !== "object") {
return errorJson(c, 400, "VALIDATION_ERROR", "Each line must be an object.");
}
const parsed = parseLine(raw as Record<string, unknown>);
if (typeof parsed === "string") return errorJson(c, 400, "VALIDATION_ERROR", parsed);
parsedLines.push(parsed);
}
if (!linesSumToAmount(parsedLines, invoice.amount)) {
return errorJson(c, 400, "VALIDATION_ERROR", "Line amounts must sum to the invoice amount.");
}
const created: LineRow[] = [];
await handle.db.transaction(async (tx) => {
await tx.delete(invoiceLines).where(eq(invoiceLines.invoiceId, id));
for (const line of parsedLines) {
const [row] = await tx
.insert(invoiceLines)
.values({ invoiceId: id, ...line })
.returning();
created.push(row);
}
});
return c.json({ items: created.map(toLine) });
});
routes.post("/invoices/:id/documents", async (c) => {
const denied = requireCan(c, "write:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid invoice id.");
const invoice = await firstById<InvoiceRow>(handle, invoices, id);
if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found.");
const body = await parseJsonBody(c);
const fileName = safeFileName(asString(body.fileName).trim());
const contentType = asString(body.contentType, "application/pdf").trim() || "application/pdf";
if (!asString(body.fileName).trim()) {
return errorJson(c, 400, "VALIDATION_ERROR", "fileName is required.");
}
const documentId = newId();
const objectKey = `invoices/${id}/${documentId}/${fileName}`;
const [row] = await handle.db
.insert(documents)
.values({
id: documentId,
invoiceId: id,
objectKey,
contentType,
fileName,
uploadedByUserId: caller(c).id,
})
.returning();
const put = await store.presignPut({ objectKey, contentType });
return c.json(toDocument(row, { uploadUrl: put.url, uploadHeaders: put.headers }), 201);
});
routes.post("/documents/:id/confirmations", async (c) => {
const denied = requireCan(c, "write:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid document id.");
const row = await firstById<DocumentRow>(handle, documents, id);
if (!row) return errorJson(c, 404, "NOT_FOUND", "Document not found.");
if (!(await store.objectExists(row.objectKey))) {
return errorJson(c, 409, "CONFLICT", "Object has not been uploaded.");
}
return c.json(toDocument(row, { downloadUrl: await store.presignGet(row.objectKey) }));
});
routes.get("/documents/:id", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid document id.");
const row = await firstById<DocumentRow>(handle, documents, id);
if (!row) return errorJson(c, 404, "NOT_FOUND", "Document not found.");
return c.json(toDocument(row, { downloadUrl: await store.presignGet(row.objectKey) }));
});
return routes;
}

View file

@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { createApp } from "../app.js";
import { loadEnv } from "../env.js";
import type { ErrorEnvelope } from "../http.js";
import { createFakeDb, emptyStore, SEED } from "../test/fake-db.js";
function expectEnvelope(body: unknown, code: string) {
const envelope = body as ErrorEnvelope;
expect(envelope.error.code).toBe(code);
}
function adminEnv() {
return loadEnv({
NODE_ENV: "test",
DEV_AUTH_BYPASS: "true",
DEV_AUTH_SUB: SEED.user.cognitoSub,
DEV_AUTH_EMAIL: SEED.user.email,
DEV_AUTH_NAME: SEED.user.name,
DEV_AUTH_ROLE: "admin",
});
}
function viewerEnv() {
return loadEnv({
NODE_ENV: "test",
DEV_AUTH_BYPASS: "true",
DEV_AUTH_SUB: SEED.user.cognitoSub,
DEV_AUTH_EMAIL: SEED.user.email,
DEV_AUTH_NAME: SEED.user.name,
DEV_AUTH_ROLE: "viewer",
});
}
describe("master data stubs", () => {
it("lists and gets seeded vendors", async () => {
const app = createApp(adminEnv(), createFakeDb());
const list = await app.request("/api/vendors");
expect(list.status).toBe(200);
const listed = (await list.json()) as { items: Array<{ id: string; name: string }> };
expect(listed.items[0]?.id).toBe(SEED.vendor.id);
const get = await app.request(`/api/vendors/${SEED.vendor.id}`);
expect(get.status).toBe(200);
await expect(get.json()).resolves.toMatchObject({ id: SEED.vendor.id, name: SEED.vendor.name });
});
it("creates a vendor and returns 201", async () => {
const app = createApp(adminEnv(), createFakeDb());
const response = await app.request("/api/vendors", {
method: "POST",
headers: { "content-type": "application/json", origin: "http://127.0.0.1:3000" },
body: JSON.stringify({ name: "Harbor Maintenance", defaultPaymentMethod: "ach" }),
});
expect(response.status).toBe(201);
await expect(response.json()).resolves.toMatchObject({
name: "Harbor Maintenance",
defaultPaymentMethod: "ach",
});
});
it("returns 404 for a missing vendor", async () => {
const store = emptyStore();
store.vendors = [];
const app = createApp(adminEnv(), createFakeDb(store));
const response = await app.request(`/api/vendors/${SEED.vendor.id}`);
expect(response.status).toBe(404);
expectEnvelope(await response.json(), "NOT_FOUND");
});
it("returns 403 when a non-admin writes vendors", async () => {
const app = createApp(viewerEnv(), createFakeDb());
const response = await app.request("/api/vendors", {
method: "POST",
headers: { "content-type": "application/json", origin: "http://127.0.0.1:3000" },
body: JSON.stringify({ name: "Nope" }),
});
expect(response.status).toBe(403);
expectEnvelope(await response.json(), "FORBIDDEN");
});
it("lists GL accounts and departments", async () => {
const app = createApp(adminEnv(), createFakeDb());
const gl = await app.request("/api/gl-accounts");
expect(gl.status).toBe(200);
const glBody = (await gl.json()) as { items: Array<{ code: string }> };
expect(glBody.items[0]?.code).toBe("6100");
const departments = await app.request("/api/departments");
expect(departments.status).toBe(200);
const deptBody = (await departments.json()) as { items: Array<{ code: string }> };
expect(deptBody.items[0]?.code).toBe("OPS");
});
it("updates a user role", async () => {
const app = createApp(adminEnv(), createFakeDb());
const response = await app.request(`/api/users/${SEED.user.id}`, {
method: "PATCH",
headers: { "content-type": "application/json", origin: "http://127.0.0.1:3000" },
body: JSON.stringify({ role: "approver" }),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ role: "approver" });
});
});

View file

@ -1,6 +1,7 @@
import { Hono } from "hono";
import type { AppBindings } from "../auth/middleware.js";
import { can } from "../auth/rbac.js";
import { errorJson } from "../http.js";
export function createMeRoutes() {
const routes = new Hono<AppBindings>();
@ -8,7 +9,7 @@ export function createMeRoutes() {
routes.get("/me", (c) => {
const user = c.get("user");
if (!can(user.role, "read:me")) {
return c.json({ error: "Forbidden." }, 403);
return errorJson(c, 403, "FORBIDDEN", "Forbidden.");
}
return c.json({

View file

@ -0,0 +1,61 @@
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import type { Db } from "../db/client.js";
import { users } from "../db/schema/index.js";
import type { AppBindings } from "../auth/middleware.js";
import { errorJson } from "../http.js";
import { iso, isUuid, parseJsonBody, requireCan } from "./helpers.js";
import { isUserRole } from "../env.js";
function toUser(row: typeof users.$inferSelect) {
return {
id: row.id,
email: row.email,
name: row.name,
role: row.role,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
export function createUserRoutes(handle: Db) {
const routes = new Hono<AppBindings>();
routes.get("/users", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const rows = await handle.db.select().from(users);
return c.json({ items: rows.map(toUser) });
});
routes.get("/users/:id", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid user id.");
const row = await handle.db.query.users.findFirst({ where: eq(users.id, id) });
if (!row) return errorJson(c, 404, "NOT_FOUND", "User not found.");
return c.json(toUser(row));
});
routes.patch("/users/:id", async (c) => {
const denied = requireCan(c, "admin:settings");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid user id.");
const existing = await handle.db.query.users.findFirst({ where: eq(users.id, id) });
if (!existing) return errorJson(c, 404, "NOT_FOUND", "User not found.");
const body = await parseJsonBody(c);
if (typeof body.role !== "string" || !isUserRole(body.role)) {
return errorJson(c, 400, "VALIDATION_ERROR", "A valid role is required.");
}
const [row] = await handle.db
.update(users)
.set({ role: body.role, updatedAt: new Date() })
.where(eq(users.id, id))
.returning();
return c.json(toUser(row));
});
return routes;
}

View file

@ -0,0 +1,97 @@
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import type { Db } from "../db/client.js";
import { vendors } from "../db/schema/index.js";
import type { AppBindings } from "../auth/middleware.js";
import { errorJson } from "../http.js";
import { asString, iso, isUuid, optionalString, parseJsonBody, requireCan } from "./helpers.js";
const PAYMENT_METHODS = ["check", "ach"] as const;
type PaymentMethod = (typeof PAYMENT_METHODS)[number];
function isPaymentMethod(value: string): value is PaymentMethod {
return (PAYMENT_METHODS as readonly string[]).includes(value);
}
function toVendor(row: typeof vendors.$inferSelect) {
return {
id: row.id,
name: row.name,
email: row.email,
defaultPaymentMethod: row.defaultPaymentMethod,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt),
};
}
export function createVendorRoutes(handle: Db) {
const routes = new Hono<AppBindings>();
routes.get("/vendors", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const rows = await handle.db.select().from(vendors);
return c.json({ items: rows.map(toVendor) });
});
routes.get("/vendors/:id", async (c) => {
const denied = requireCan(c, "read:invoices");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendor id.");
const row = await handle.db.query.vendors.findFirst({ where: eq(vendors.id, id) });
if (!row) return errorJson(c, 404, "NOT_FOUND", "Vendor not found.");
return c.json(toVendor(row));
});
routes.post("/vendors", async (c) => {
const denied = requireCan(c, "admin:settings");
if (denied) return denied;
const body = await parseJsonBody(c);
const name = asString(body.name).trim();
if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required.");
const method = asString(body.defaultPaymentMethod, "check");
if (!isPaymentMethod(method)) {
return errorJson(c, 400, "VALIDATION_ERROR", "Invalid defaultPaymentMethod.");
}
const [row] = await handle.db
.insert(vendors)
.values({
name,
email: optionalString(body.email) ?? null,
defaultPaymentMethod: method,
})
.returning();
return c.json(toVendor(row), 201);
});
routes.patch("/vendors/:id", async (c) => {
const denied = requireCan(c, "admin:settings");
if (denied) return denied;
const id = c.req.param("id");
if (!isUuid(id)) return errorJson(c, 400, "VALIDATION_ERROR", "Invalid vendor id.");
const existing = await handle.db.query.vendors.findFirst({ where: eq(vendors.id, id) });
if (!existing) return errorJson(c, 404, "NOT_FOUND", "Vendor not found.");
const body = await parseJsonBody(c);
const patch: Partial<typeof vendors.$inferInsert> = { updatedAt: new Date() };
if (body.name !== undefined) {
const name = asString(body.name).trim();
if (!name) return errorJson(c, 400, "VALIDATION_ERROR", "Name is required.");
patch.name = name;
}
if (body.email !== undefined) {
patch.email = optionalString(body.email) ?? null;
}
if (body.defaultPaymentMethod !== undefined) {
const method = asString(body.defaultPaymentMethod);
if (!isPaymentMethod(method)) {
return errorJson(c, 400, "VALIDATION_ERROR", "Invalid defaultPaymentMethod.");
}
patch.defaultPaymentMethod = method;
}
const [row] = await handle.db.update(vendors).set(patch).where(eq(vendors.id, id)).returning();
return c.json(toVendor(row));
});
return routes;
}

View file

@ -0,0 +1,261 @@
import { getTableName } from "drizzle-orm";
import { vi } from "vitest";
import type { Db } from "../db/client.js";
import type { UserRole } from "../env.js";
export const SEED = {
user: {
id: "11111111-1111-4111-8111-111111111111",
cognitoSub: "seed-sub-admin",
email: "admin@seahavenind.com",
name: "Dev Admin",
role: "admin" as UserRole,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
},
vendor: {
id: "55555555-5555-4555-8555-555555555555",
name: "Acme Facilities Supply",
email: "billing@acmefacilities.example",
defaultPaymentMethod: "check" as const,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
},
gl: {
id: "66666666-6666-4666-8666-666666666666",
code: "6100",
name: "Facilities Expense",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
},
department: {
id: "77777777-7777-4777-8777-777777777777",
code: "OPS",
name: "Operations",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
},
invoice: {
id: "88888888-8888-4888-8888-888888888888",
vendorId: "55555555-5555-4555-8555-555555555555",
invoiceNumber: "INV-1001",
amount: "1250.00",
amountDue: "1250.00",
dueDate: "2026-09-01",
payDate: null as string | null,
sendPaymentOn: null as string | null,
status: "pending_approval" as const,
paymentMethod: "check" as const,
memo: "Seed invoice for local smoke.",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
},
line: {
id: "99999999-9999-4999-8999-999999999999",
invoiceId: "88888888-8888-4888-8888-888888888888",
description: "Monthly maintenance",
amount: "1250.00",
glAccountId: "66666666-6666-4666-8666-666666666666",
departmentId: "77777777-7777-4777-8777-777777777777",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
},
document: {
id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
invoiceId: "88888888-8888-4888-8888-888888888888",
objectKey: "seed/inv-1001.pdf",
contentType: "application/pdf",
fileName: "inv-1001.pdf",
uploadedByUserId: "11111111-1111-4111-8111-111111111111",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
},
policy: {
id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
name: "Default approver policy",
priority: 10,
amountThreshold: "0.00",
skipBelowAmount: "25.00",
active: true,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
},
step: {
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
invoiceId: "88888888-8888-4888-8888-888888888888",
policyId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
stepOrder: 1,
approverRole: "approver" as UserRole,
assigneeUserId: null as string | null,
status: "pending" as const,
actedByUserId: null as string | null,
actedAt: null as Date | null,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
},
comment: {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
invoiceId: "88888888-8888-4888-8888-888888888888",
authorUserId: "11111111-1111-4111-8111-111111111111",
body: "Seed comment on INV-1001.",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
},
activity: {
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
invoiceId: "88888888-8888-4888-8888-888888888888",
actorUserId: "11111111-1111-4111-8111-111111111111",
message: "Invoice created from seed.",
createdAt: new Date("2026-01-01T00:00:00.000Z"),
},
};
export type Store = {
users: Array<typeof SEED.user>;
vendors: Array<typeof SEED.vendor>;
glAccounts: Array<typeof SEED.gl>;
departments: Array<typeof SEED.department>;
invoices: Array<typeof SEED.invoice>;
invoiceLines: Array<typeof SEED.line>;
documents: Array<typeof SEED.document>;
approvalPolicies: Array<typeof SEED.policy>;
approvalSteps: Array<typeof SEED.step>;
invoiceComments: Array<typeof SEED.comment>;
activityLog: Array<typeof SEED.activity>;
};
export function emptyStore(): Store {
return {
users: [{ ...SEED.user }],
vendors: [{ ...SEED.vendor }],
glAccounts: [{ ...SEED.gl }],
departments: [{ ...SEED.department }],
invoices: [{ ...SEED.invoice }],
invoiceLines: [{ ...SEED.line }],
documents: [{ ...SEED.document }],
approvalPolicies: [{ ...SEED.policy }],
approvalSteps: [{ ...SEED.step }],
invoiceComments: [{ ...SEED.comment }],
activityLog: [{ ...SEED.activity }],
};
}
function rowsFor(store: Store, table: unknown): Array<Record<string, unknown>> {
switch (getTableName(table as never)) {
case "users":
return store.users;
case "vendors":
return store.vendors;
case "gl_accounts":
return store.glAccounts;
case "departments":
return store.departments;
case "invoices":
return store.invoices;
case "invoice_lines":
return store.invoiceLines;
case "documents":
return store.documents;
case "approval_policies":
return store.approvalPolicies;
case "approval_steps":
return store.approvalSteps;
case "invoice_comments":
return store.invoiceComments;
case "activity_log":
return store.activityLog;
default:
return [];
}
}
function findById(rows: Array<Record<string, unknown>>, id: string) {
return rows.find((row) => row.id === id) ?? null;
}
export function createFakeDb(store: Store = emptyStore()): Db {
const queryFind = (tableName: keyof Store) => ({
findFirst: vi.fn(async (opts?: { where?: unknown }) => {
const rows = store[tableName] as Array<Record<string, unknown>>;
if (!opts) return rows[0] ?? null;
// Routes always look up by primary key; return the first seeded row or null via tests mutating store.
return rows[0] ?? null;
}),
findMany: vi.fn(async () => store[tableName]),
});
const db = {
execute: vi.fn(async () => []),
select: vi.fn(() => ({
from: vi.fn(async (table: unknown) => rowsFor(store, table)),
})),
query: {
users: queryFind("users"),
vendors: queryFind("vendors"),
glAccounts: queryFind("glAccounts"),
departments: queryFind("departments"),
invoices: queryFind("invoices"),
invoiceLines: queryFind("invoiceLines"),
documents: queryFind("documents"),
approvalPolicies: queryFind("approvalPolicies"),
approvalSteps: queryFind("approvalSteps"),
invoiceComments: queryFind("invoiceComments"),
activityLog: queryFind("activityLog"),
},
insert: vi.fn((table: unknown) => ({
values: vi.fn((value: Record<string, unknown>) => ({
returning: vi.fn(async () => {
const now = new Date();
const row = {
id: typeof value.id === "string" ? value.id : crypto.randomUUID(),
createdAt: now,
updatedAt: now,
...value,
};
rowsFor(store, table).push(row);
return [row];
}),
})),
})),
update: vi.fn((table: unknown) => ({
set: vi.fn((patch: Record<string, unknown>) => ({
where: vi.fn(() => ({
returning: vi.fn(async () => {
const rows = rowsFor(store, table);
const current =
(typeof patch.id === "string" && rows.find((row) => row.id === patch.id)) || rows[0];
if (!current) return [];
Object.assign(current, patch);
return [current];
}),
})),
})),
})),
delete: vi.fn((table: unknown) => ({
where: vi.fn(async () => {
const rows = rowsFor(store, table);
rows.splice(0, rows.length);
}),
})),
transaction: vi.fn(async (callback: (tx: never) => Promise<unknown>) => {
const snapshot = structuredClone(store) as Store;
try {
return await callback(db as never);
} catch (error) {
(Object.keys(store) as Array<keyof Store>).forEach((key) => {
const rows = store[key] as unknown as Array<Record<string, unknown>>;
rows.splice(
0,
rows.length,
...(snapshot[key] as unknown as Array<Record<string, unknown>>),
);
});
throw error;
}
}),
};
return {
driver: "postgres",
pool: { end: vi.fn(async () => undefined) } as never,
db: db as never,
};
}
export { findById };

View file

@ -3,5 +3,5 @@
"compilerOptions": {
"noEmit": false
},
"exclude": ["src/**/*.test.ts"]
"exclude": ["src/**/*.test.ts", "src/test/**"]
}

View file

@ -21,9 +21,9 @@ rules:
assertions:
defined: true
operation-4xx-response: error
# Off: the live contract is {"error": string} as plain application/json
# (serialization.py error_response). Adopting RFC 7807 would be a runtime
# + SHOC-contract change, decided against 2026-07-24.
# Off: the live contract is {"error": { code, message, correlationId }} as
# plain application/json, matching the portal BFF envelope. RFC 7807 is not
# the AP contract.
operation-4xx-problem-details-rfc7807: off
operation-operationId: error
rule/operationId-casing:
@ -64,8 +64,15 @@ rules:
- docs
- openapi.json
- health
- ready
- me
- api
- auth
- login
- callback
- refresh
- logout
- inbox
paths-kebab-case: error
no-invalid-schema-examples: error
# No schema-properties casing rule: property names mirror the DynamoDB
@ -96,12 +103,9 @@ rules:
- application/json
- text/html
no-server-example.com: error
rule/no-server-localhost:
subject:
type: Server
property: url
assertions:
notPattern: /(localhost|127.0.0.1)
# This batch documents the local API only (http://127.0.0.1:8787). Production
# hostname documentation is AP-12.
rule/no-server-localhost: off
operation-singular-tag: error
operation-tag-defined: error
rule/tag-description:

58
src/api/client.test.ts Normal file
View file

@ -0,0 +1,58 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { apiFetch, readApiJson } from "@/api/client";
function walk(dir: string): string[] {
const entries = readdirSync(dir);
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
files.push(...walk(full));
} else if (full.endsWith(".ts") || full.endsWith(".tsx")) {
files.push(full);
}
}
return files;
}
describe("unused SPA API client", () => {
it("sends credentials and never sets Authorization", async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stage: "local", sha: "x" })));
vi.stubGlobal("fetch", fetchMock);
await apiFetch("/api/health", { headers: { Authorization: "Bearer leaked" } });
expect(fetchMock).toHaveBeenCalledWith(
"/api/health",
expect.objectContaining({ credentials: "include" }),
);
const init = (fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1];
expect(init.credentials).toBe("include");
const headers = new Headers(init.headers);
expect(headers.get("Authorization")).toBeNull();
expect(headers.get("Accept")).toBe("application/json");
vi.unstubAllGlobals();
});
it("is not imported from pages, domain, or mocks", () => {
const roots = ["src/pages", "src/domain", "src/mocks"].map((dir) => join(process.cwd(), dir));
const hits: string[] = [];
for (const root of roots) {
for (const file of walk(root)) {
const text = readFileSync(file, "utf8");
if (text.includes("@/api/client") || text.includes("src/api/client")) {
hits.push(file);
}
}
}
expect(hits).toEqual([]);
});
it("parses JSON success bodies", async () => {
const body = await readApiJson<{ stage: string }>(
new Response(JSON.stringify({ stage: "local" }), { status: 200 }),
);
expect(body.stage).toBe("local");
});
});

87
src/api/client.ts Normal file
View file

@ -0,0 +1,87 @@
import type { ErrorEnvelope } from "@/api/types";
function pathnameOf(input: RequestInfo | URL): string {
const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
try {
return new URL(raw, "http://local.invalid").pathname;
} catch {
return raw.split("?")[0] ?? raw;
}
}
function skipRefresh(input: RequestInfo | URL): boolean {
const path = pathnameOf(input);
return (
path === "/api/auth/login" ||
path === "/api/auth/callback" ||
path === "/api/auth/refresh" ||
path === "/api/auth/logout"
);
}
let refreshInFlight: Promise<boolean> | null = null;
async function refreshSession(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
headers: { Accept: "application/json" },
})
.then((response) => response.status === 204)
.catch(() => false)
.finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
export async function apiFetch(
input: RequestInfo | URL,
init: RequestInit = {},
): Promise<Response> {
const headers = new Headers(init.headers);
if (!headers.has("Accept")) headers.set("Accept", "application/json");
headers.delete("Authorization");
const requestInit: RequestInit = { ...init, credentials: "include", headers };
const response = await fetch(input, requestInit);
if ((response.status !== 401 && response.status !== 403) || skipRefresh(input)) {
return response;
}
const refreshed = await refreshSession();
if (refreshed) return fetch(input, requestInit);
return response;
}
export class ApiError extends Error {
readonly status: number;
readonly code?: string;
constructor(message: string, status: number, code?: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
}
}
export async function readApiJson<T>(response: Response): Promise<T> {
const text = await response.text();
let body: T & Partial<ErrorEnvelope>;
try {
body = JSON.parse(text) as T & Partial<ErrorEnvelope>;
} catch {
throw response.ok
? new Error("Invalid JSON from API")
: new ApiError(`HTTP ${response.status}`, response.status);
}
if (!response.ok) {
throw new ApiError(
body.error?.message || `HTTP ${response.status}`,
response.status,
body.error?.code,
);
}
return body;
}

123
src/api/types.ts Normal file
View file

@ -0,0 +1,123 @@
export type ErrorEnvelope = {
error: {
code: string;
message: string;
correlationId: string;
};
};
export type HealthResponse = {
stage: string;
sha: string;
};
export type MeResponse = {
id: string;
email: string;
name: string;
role: "admin" | "ap_processor" | "approver" | "viewer";
};
export type Vendor = {
id: string;
name: string;
email: string | null;
defaultPaymentMethod: "check" | "ach";
createdAt: string;
updatedAt: string;
};
export type GlAccount = {
id: string;
code: string;
name: string;
createdAt: string;
updatedAt: string;
};
export type Department = {
id: string;
code: string;
name: string;
createdAt: string;
updatedAt: string;
};
export type User = {
id: string;
email: string;
name: string;
role: MeResponse["role"];
createdAt: string;
updatedAt: string;
};
export type InvoiceLine = {
id: string;
invoiceId: string;
description: string;
amount: string;
glAccountId: string | null;
departmentId: string | null;
createdAt: string;
};
export type Invoice = {
id: string;
vendorId: string;
invoiceNumber: string;
amount: string;
amountDue: string;
dueDate: string;
payDate: string | null;
sendPaymentOn: string | null;
status: "pending_approval" | "approved" | "scheduled" | "paid" | "rejected" | "void";
paymentMethod: "check" | "ach";
memo: string;
createdAt: string;
updatedAt: string;
lines: InvoiceLine[];
};
export type Document = {
id: string;
invoiceId: string | null;
objectKey: string;
contentType: string;
fileName: string;
uploadedByUserId: string | null;
createdAt: string;
uploadUrl?: string;
uploadHeaders?: Record<string, string>;
downloadUrl?: string;
};
export type ApiPaths = {
"/api/health": {
get: { response: HealthResponse };
};
"/api/me": {
get: { response: MeResponse };
};
"/api/vendors": {
get: { response: { items: Vendor[] } };
};
"/api/gl-accounts": {
get: { response: { items: GlAccount[] } };
};
"/api/departments": {
get: { response: { items: Department[] } };
};
"/api/users": {
get: { response: { items: User[] } };
};
"/api/invoices": {
get: { response: { items: Invoice[] } };
};
"/api/documents/{id}": {
get: { response: Document };
};
"/api/inbox": {
get: { response: { items: Array<{ id: string; invoiceId: string; status: string }> } };
};
};