mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 06:53:18 +00:00
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:
parent
81d5d5c97a
commit
864531b51e
80 changed files with 5992 additions and 153 deletions
21
.dockerignore
Normal file
21
.dockerignore
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
.git
|
||||
.github
|
||||
.cursor
|
||||
dist
|
||||
packages/*/dist
|
||||
build
|
||||
coverage
|
||||
e2e
|
||||
node_modules
|
||||
src
|
||||
public
|
||||
placeholder
|
||||
terraform
|
||||
docs
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
playwright-report
|
||||
test-results
|
||||
.idea
|
||||
.vscode
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
27
Dockerfile
Normal 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"]
|
||||
|
|
@ -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
74
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Sea Haven AP API
|
||||
|
||||
HTTP API for Sea Haven accounts payable (`ap.seahaven.com`).
|
||||
HTTP API for Sea Haven accounts payable. Local server is `http://127.0.0.1:8787`.
|
||||
|
||||
This foundation documents the health and session smoke surface introduced in
|
||||
AP-14. Domain CRUD lands in later tickets and extends this OpenAPI tree.
|
||||
Liveness is `GET /api/health` (`{ stage, sha }`) with no auth and no database
|
||||
ping. Readiness is optional `GET /api/ready`. Errors use
|
||||
`{ error: { code, message, correlationId } }`.
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ Defaults:
|
|||
## Smoke
|
||||
|
||||
```bash
|
||||
curl -s http://127.0.0.1:8787/health
|
||||
curl -s http://127.0.0.1:8787/api/health
|
||||
curl -s http://127.0.0.1:8787/api/me
|
||||
```
|
||||
|
||||
|
|
@ -34,5 +34,5 @@ npm run docs:preview
|
|||
```
|
||||
|
||||
`docs:preview` runs `redocly build-docs` (CLI v2) and opens the HTML at
|
||||
`/tmp/seahaven-ap-api-docs.html`. Published OpenAPI servers point at
|
||||
`https://ap.seahaven.com`. Use this page for the local docs view.
|
||||
`/tmp/seahaven-ap-api-docs.html`. OpenAPI servers point at
|
||||
`http://127.0.0.1:8787`. Use this page for the local docs view.
|
||||
|
|
|
|||
|
|
@ -4,10 +4,39 @@ Error:
|
|||
- error
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: Human-readable error message.
|
||||
example: Missing or invalid Authorization header.
|
||||
type: object
|
||||
required:
|
||||
- code
|
||||
- message
|
||||
- correlationId
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code.
|
||||
example: NOT_FOUND
|
||||
message:
|
||||
type: string
|
||||
description: Human-readable error message.
|
||||
example: Not found.
|
||||
correlationId:
|
||||
type: string
|
||||
description: Request correlation identifier echoed from x-correlation-id when present.
|
||||
example: 11111111-1111-4111-8111-111111111111
|
||||
HealthResponse:
|
||||
type: object
|
||||
required:
|
||||
- stage
|
||||
- sha
|
||||
properties:
|
||||
stage:
|
||||
type: string
|
||||
description: Deployment stage name.
|
||||
example: local
|
||||
sha:
|
||||
type: string
|
||||
description: Git SHA inlined at image build.
|
||||
example: deadbeef
|
||||
ReadyResponse:
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
|
|
@ -15,7 +44,7 @@ HealthResponse:
|
|||
properties:
|
||||
status:
|
||||
type: string
|
||||
description: Process health marker.
|
||||
description: Process readiness marker.
|
||||
example: ok
|
||||
database:
|
||||
type: string
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
91
packages/api/openapi/paths/approval-policies-id.yaml
Normal file
91
packages/api/openapi/paths/approval-policies-id.yaml
Normal 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
|
||||
71
packages/api/openapi/paths/approval-policies.yaml
Normal file
71
packages/api/openapi/paths/approval-policies.yaml
Normal 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
|
||||
57
packages/api/openapi/paths/approval-steps-id-decisions.yaml
Normal file
57
packages/api/openapi/paths/approval-steps-id-decisions.yaml
Normal 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
|
||||
32
packages/api/openapi/paths/auth-callback.yaml
Normal file
32
packages/api/openapi/paths/auth-callback.yaml
Normal 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.
|
||||
30
packages/api/openapi/paths/auth-login.yaml
Normal file
30
packages/api/openapi/paths/auth-login.yaml
Normal 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
|
||||
21
packages/api/openapi/paths/auth-logout.yaml
Normal file
21
packages/api/openapi/paths/auth-logout.yaml
Normal 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
|
||||
32
packages/api/openapi/paths/auth-refresh.yaml
Normal file
32
packages/api/openapi/paths/auth-refresh.yaml
Normal 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
|
||||
82
packages/api/openapi/paths/departments-id.yaml
Normal file
82
packages/api/openapi/paths/departments-id.yaml
Normal 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
|
||||
68
packages/api/openapi/paths/departments.yaml
Normal file
68
packages/api/openapi/paths/departments.yaml
Normal 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
|
||||
52
packages/api/openapi/paths/documents-id-confirmations.yaml
Normal file
52
packages/api/openapi/paths/documents-id-confirmations.yaml
Normal 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
|
||||
39
packages/api/openapi/paths/documents-id.yaml
Normal file
39
packages/api/openapi/paths/documents-id.yaml
Normal 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
|
||||
82
packages/api/openapi/paths/gl-accounts-id.yaml
Normal file
82
packages/api/openapi/paths/gl-accounts-id.yaml
Normal 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
|
||||
68
packages/api/openapi/paths/gl-accounts.yaml
Normal file
68
packages/api/openapi/paths/gl-accounts.yaml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
24
packages/api/openapi/paths/inbox.yaml
Normal file
24
packages/api/openapi/paths/inbox.yaml
Normal 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
|
||||
45
packages/api/openapi/paths/invoices-id-activity-logs.yaml
Normal file
45
packages/api/openapi/paths/invoices-id-activity-logs.yaml
Normal 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
|
||||
80
packages/api/openapi/paths/invoices-id-comments.yaml
Normal file
80
packages/api/openapi/paths/invoices-id-comments.yaml
Normal 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
|
||||
53
packages/api/openapi/paths/invoices-id-documents.yaml
Normal file
53
packages/api/openapi/paths/invoices-id-documents.yaml
Normal 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
|
||||
123
packages/api/openapi/paths/invoices-id-lines.yaml
Normal file
123
packages/api/openapi/paths/invoices-id-lines.yaml
Normal 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
|
||||
107
packages/api/openapi/paths/invoices-id.yaml
Normal file
107
packages/api/openapi/paths/invoices-id.yaml
Normal 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
|
||||
106
packages/api/openapi/paths/invoices.yaml
Normal file
106
packages/api/openapi/paths/invoices.yaml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
28
packages/api/openapi/paths/ready.yaml
Normal file
28
packages/api/openapi/paths/ready.yaml
Normal 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
|
||||
81
packages/api/openapi/paths/users-id.yaml
Normal file
81
packages/api/openapi/paths/users-id.yaml
Normal 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
|
||||
24
packages/api/openapi/paths/users.yaml
Normal file
24
packages/api/openapi/paths/users.yaml
Normal 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
|
||||
86
packages/api/openapi/paths/vendors-id.yaml
Normal file
86
packages/api/openapi/paths/vendors-id.yaml
Normal 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
|
||||
72
packages/api/openapi/paths/vendors.yaml
Normal file
72
packages/api/openapi/paths/vendors.yaml
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
95
packages/api/src/approvals.ts
Normal file
95
packages/api/src/approvals.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
104
packages/api/src/auth/cognito.ts
Normal file
104
packages/api/src/auth/cognito.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
138
packages/api/src/auth/cookies.test.ts
Normal file
138
packages/api/src/auth/cookies.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
248
packages/api/src/auth/cookies.ts
Normal file
248
packages/api/src/auth/cookies.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
20
packages/api/src/auth/oauth.test.ts
Normal file
20
packages/api/src/auth/oauth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
229
packages/api/src/auth/oauth.ts
Normal file
229
packages/api/src/auth/oauth.ts
Normal 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);
|
||||
}
|
||||
18
packages/api/src/auth/origin-verify.ts
Normal file
18
packages/api/src/auth/origin-verify.ts
Normal 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);
|
||||
}
|
||||
23
packages/api/src/auth/pkce.ts
Normal file
23
packages/api/src/auth/pkce.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,30 +22,37 @@ 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 {
|
||||
driver: "postgres",
|
||||
pool: { end: vi.fn(async () => undefined) } as never,
|
||||
db: {
|
||||
query: { users: { findFirst } },
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
handle: {
|
||||
driver: "postgres",
|
||||
pool: { end: vi.fn(async () => undefined) } as never,
|
||||
db: {
|
||||
query: { users: { findFirst } },
|
||||
update: vi.fn(() => ({
|
||||
set: setSpy,
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({
|
||||
returning: vi.fn(async () => [returningRow]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({
|
||||
returning: vi.fn(async () => [returningRow]),
|
||||
})),
|
||||
})),
|
||||
} as never,
|
||||
} 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",
|
||||
|
|
|
|||
|
|
@ -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 patch: {
|
||||
email: string;
|
||||
name: string;
|
||||
role?: UserRole;
|
||||
updatedAt: Date;
|
||||
} = {
|
||||
email: identity.email,
|
||||
name: identity.name,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (identity.role !== undefined) {
|
||||
patch.role = identity.role;
|
||||
}
|
||||
const [updated] = await handle.db
|
||||
.update(users)
|
||||
.set({
|
||||
email: identity.email,
|
||||
name: identity.name,
|
||||
role: identity.role,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.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();
|
||||
|
||||
|
|
|
|||
9
packages/api/src/build-info.ts
Normal file
9
packages/api/src/build-info.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Build-time constant. The API image Dockerfile overwrites the compiled
|
||||
// `build-info.js` with an inlined GIT_SHA after `tsc`. Keep the exact
|
||||
// expression `process.env.GIT_SHA` here so local `tsx` still reads the env.
|
||||
//
|
||||
// Consumers must prefer BUILD_GIT_SHA over a runtime GIT_SHA env var. A
|
||||
// deployed task can carry a stale env var from an earlier infra apply while
|
||||
// its image has since been updated by deploy-api.yaml.
|
||||
|
||||
export const BUILD_GIT_SHA: string = process.env.GIT_SHA?.trim() || "";
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
77
packages/api/src/documents.ts
Normal file
77
packages/api/src/documents.ts
Normal 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`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
57
packages/api/src/http.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
160
packages/api/src/routes/approvals.test.ts
Normal file
160
packages/api/src/routes/approvals.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
293
packages/api/src/routes/approvals.ts
Normal file
293
packages/api/src/routes/approvals.ts
Normal 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;
|
||||
}
|
||||
16
packages/api/src/routes/auth.ts
Normal file
16
packages/api/src/routes/auth.ts
Normal 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;
|
||||
}
|
||||
80
packages/api/src/routes/departments.ts
Normal file
80
packages/api/src/routes/departments.ts
Normal 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;
|
||||
}
|
||||
78
packages/api/src/routes/gl-accounts.ts
Normal file
78
packages/api/src/routes/gl-accounts.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
94
packages/api/src/routes/helpers.ts
Normal file
94
packages/api/src/routes/helpers.ts
Normal 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 };
|
||||
261
packages/api/src/routes/invoices.test.ts
Normal file
261
packages/api/src/routes/invoices.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
463
packages/api/src/routes/invoices.ts
Normal file
463
packages/api/src/routes/invoices.ts
Normal 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;
|
||||
}
|
||||
102
packages/api/src/routes/master-data.test.ts
Normal file
102
packages/api/src/routes/master-data.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
61
packages/api/src/routes/users.ts
Normal file
61
packages/api/src/routes/users.ts
Normal 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;
|
||||
}
|
||||
97
packages/api/src/routes/vendors.ts
Normal file
97
packages/api/src/routes/vendors.ts
Normal 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;
|
||||
}
|
||||
261
packages/api/src/test/fake-db.ts
Normal file
261
packages/api/src/test/fake-db.ts
Normal 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 };
|
||||
|
|
@ -3,5 +3,5 @@
|
|||
"compilerOptions": {
|
||||
"noEmit": false
|
||||
},
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"exclude": ["src/**/*.test.ts", "src/test/**"]
|
||||
}
|
||||
|
|
|
|||
22
redocly.yaml
22
redocly.yaml
|
|
@ -21,9 +21,9 @@ rules:
|
|||
assertions:
|
||||
defined: true
|
||||
operation-4xx-response: error
|
||||
# Off: the live contract is {"error": string} as plain application/json
|
||||
# (serialization.py error_response). Adopting RFC 7807 would be a runtime
|
||||
# + SHOC-contract change, decided against 2026-07-24.
|
||||
# Off: the live contract is {"error": { code, message, correlationId }} as
|
||||
# plain application/json, matching the portal BFF envelope. RFC 7807 is not
|
||||
# the AP contract.
|
||||
operation-4xx-problem-details-rfc7807: off
|
||||
operation-operationId: error
|
||||
rule/operationId-casing:
|
||||
|
|
@ -64,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
58
src/api/client.test.ts
Normal 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
87
src/api/client.ts
Normal 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
123
src/api/types.ts
Normal 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 }> } };
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue