From 864531b51e4c432300a0b21219d71bfd1469bb9f Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:43:44 +0000 Subject: [PATCH] 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 --- .dockerignore | 21 + .env.example | 3 + .redocly.lint-ignore.yaml | 17 +- Dockerfile | 27 + README.md | 6 +- package-lock.json | 74 +++ packages/api/docs/auth.md | 33 +- packages/api/docs/index.md | 7 +- packages/api/docs/local-dev.md | 6 +- packages/api/openapi/components/schemas.yaml | 440 ++++++++++++++++- packages/api/openapi/components/security.yaml | 10 +- packages/api/openapi/openapi.yaml | 100 +++- .../openapi/paths/approval-policies-id.yaml | 91 ++++ .../api/openapi/paths/approval-policies.yaml | 71 +++ .../paths/approval-steps-id-decisions.yaml | 57 +++ packages/api/openapi/paths/auth-callback.yaml | 32 ++ packages/api/openapi/paths/auth-login.yaml | 30 ++ packages/api/openapi/paths/auth-logout.yaml | 21 + packages/api/openapi/paths/auth-refresh.yaml | 32 ++ .../api/openapi/paths/departments-id.yaml | 82 ++++ packages/api/openapi/paths/departments.yaml | 68 +++ .../paths/documents-id-confirmations.yaml | 52 ++ packages/api/openapi/paths/documents-id.yaml | 39 ++ .../api/openapi/paths/gl-accounts-id.yaml | 82 ++++ packages/api/openapi/paths/gl-accounts.yaml | 68 +++ packages/api/openapi/paths/health.yaml | 28 +- packages/api/openapi/paths/inbox.yaml | 24 + .../paths/invoices-id-activity-logs.yaml | 45 ++ .../openapi/paths/invoices-id-comments.yaml | 80 +++ .../openapi/paths/invoices-id-documents.yaml | 53 ++ .../api/openapi/paths/invoices-id-lines.yaml | 123 +++++ packages/api/openapi/paths/invoices-id.yaml | 107 ++++ packages/api/openapi/paths/invoices.yaml | 106 ++++ packages/api/openapi/paths/me.yaml | 19 +- packages/api/openapi/paths/ready.yaml | 28 ++ packages/api/openapi/paths/users-id.yaml | 81 +++ packages/api/openapi/paths/users.yaml | 24 + packages/api/openapi/paths/vendors-id.yaml | 86 ++++ packages/api/openapi/paths/vendors.yaml | 72 +++ packages/api/package.json | 2 + packages/api/src/app.test.ts | 184 ++++++- packages/api/src/app.ts | 57 ++- packages/api/src/approvals.ts | 95 ++++ packages/api/src/auth/cognito.ts | 104 ++++ packages/api/src/auth/cookies.test.ts | 138 ++++++ packages/api/src/auth/cookies.ts | 248 ++++++++++ packages/api/src/auth/middleware.ts | 71 ++- packages/api/src/auth/oauth.test.ts | 20 + packages/api/src/auth/oauth.ts | 229 +++++++++ packages/api/src/auth/origin-verify.ts | 18 + packages/api/src/auth/pkce.ts | 23 + packages/api/src/auth/upsert-user.test.ts | 83 +++- packages/api/src/auth/upsert-user.ts | 26 +- packages/api/src/build-info.ts | 9 + packages/api/src/db/client.ts | 11 + packages/api/src/db/migrate.ts | 7 +- packages/api/src/documents.ts | 77 +++ packages/api/src/env.test.ts | 21 +- packages/api/src/env.ts | 39 +- packages/api/src/http.ts | 57 +++ packages/api/src/index.ts | 4 +- packages/api/src/routes/approvals.test.ts | 160 ++++++ packages/api/src/routes/approvals.ts | 293 +++++++++++ packages/api/src/routes/auth.ts | 16 + packages/api/src/routes/departments.ts | 80 +++ packages/api/src/routes/gl-accounts.ts | 78 +++ packages/api/src/routes/health.ts | 16 +- packages/api/src/routes/helpers.ts | 94 ++++ packages/api/src/routes/invoices.test.ts | 261 ++++++++++ packages/api/src/routes/invoices.ts | 463 ++++++++++++++++++ packages/api/src/routes/master-data.test.ts | 102 ++++ packages/api/src/routes/me.ts | 3 +- packages/api/src/routes/users.ts | 61 +++ packages/api/src/routes/vendors.ts | 97 ++++ packages/api/src/test/fake-db.ts | 261 ++++++++++ packages/api/tsconfig.build.json | 2 +- redocly.yaml | 22 +- src/api/client.test.ts | 58 +++ src/api/client.ts | 87 ++++ src/api/types.ts | 123 +++++ 80 files changed, 5992 insertions(+), 153 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 packages/api/openapi/paths/approval-policies-id.yaml create mode 100644 packages/api/openapi/paths/approval-policies.yaml create mode 100644 packages/api/openapi/paths/approval-steps-id-decisions.yaml create mode 100644 packages/api/openapi/paths/auth-callback.yaml create mode 100644 packages/api/openapi/paths/auth-login.yaml create mode 100644 packages/api/openapi/paths/auth-logout.yaml create mode 100644 packages/api/openapi/paths/auth-refresh.yaml create mode 100644 packages/api/openapi/paths/departments-id.yaml create mode 100644 packages/api/openapi/paths/departments.yaml create mode 100644 packages/api/openapi/paths/documents-id-confirmations.yaml create mode 100644 packages/api/openapi/paths/documents-id.yaml create mode 100644 packages/api/openapi/paths/gl-accounts-id.yaml create mode 100644 packages/api/openapi/paths/gl-accounts.yaml create mode 100644 packages/api/openapi/paths/inbox.yaml create mode 100644 packages/api/openapi/paths/invoices-id-activity-logs.yaml create mode 100644 packages/api/openapi/paths/invoices-id-comments.yaml create mode 100644 packages/api/openapi/paths/invoices-id-documents.yaml create mode 100644 packages/api/openapi/paths/invoices-id-lines.yaml create mode 100644 packages/api/openapi/paths/invoices-id.yaml create mode 100644 packages/api/openapi/paths/invoices.yaml create mode 100644 packages/api/openapi/paths/ready.yaml create mode 100644 packages/api/openapi/paths/users-id.yaml create mode 100644 packages/api/openapi/paths/users.yaml create mode 100644 packages/api/openapi/paths/vendors-id.yaml create mode 100644 packages/api/openapi/paths/vendors.yaml create mode 100644 packages/api/src/approvals.ts create mode 100644 packages/api/src/auth/cognito.ts create mode 100644 packages/api/src/auth/cookies.test.ts create mode 100644 packages/api/src/auth/cookies.ts create mode 100644 packages/api/src/auth/oauth.test.ts create mode 100644 packages/api/src/auth/oauth.ts create mode 100644 packages/api/src/auth/origin-verify.ts create mode 100644 packages/api/src/auth/pkce.ts create mode 100644 packages/api/src/build-info.ts create mode 100644 packages/api/src/documents.ts create mode 100644 packages/api/src/http.ts create mode 100644 packages/api/src/routes/approvals.test.ts create mode 100644 packages/api/src/routes/approvals.ts create mode 100644 packages/api/src/routes/auth.ts create mode 100644 packages/api/src/routes/departments.ts create mode 100644 packages/api/src/routes/gl-accounts.ts create mode 100644 packages/api/src/routes/helpers.ts create mode 100644 packages/api/src/routes/invoices.test.ts create mode 100644 packages/api/src/routes/invoices.ts create mode 100644 packages/api/src/routes/master-data.test.ts create mode 100644 packages/api/src/routes/users.ts create mode 100644 packages/api/src/routes/vendors.ts create mode 100644 packages/api/src/test/fake-db.ts create mode 100644 src/api/client.test.ts create mode 100644 src/api/client.ts create mode 100644 src/api/types.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6bb9975 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index 6d07ee7..9099a4d 100644 --- a/.env.example +++ b/.env.example @@ -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/ # COGNITO_AUDIENCE= +# COGNITO_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 diff --git a/.redocly.lint-ignore.yaml b/.redocly.lint-ignore.yaml index 871a222..7b3f1bf 100644 --- a/.redocly.lint-ignore.yaml +++ b/.redocly.lint-ignore.yaml @@ -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' diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..01279b5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 3ad12bc..480e36c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 87cdce2..4e3c4d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/api/docs/auth.md b/packages/api/docs/auth.md index 9d3e01c..a52b634 100644 --- a/packages/api/docs/auth.md +++ b/packages/api/docs/auth.md @@ -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: diff --git a/packages/api/docs/index.md b/packages/api/docs/index.md index ca1808a..a403a84 100644 --- a/packages/api/docs/index.md +++ b/packages/api/docs/index.md @@ -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 } }`. diff --git a/packages/api/docs/local-dev.md b/packages/api/docs/local-dev.md index 7b02dcd..92b7ef9 100644 --- a/packages/api/docs/local-dev.md +++ b/packages/api/docs/local-dev.md @@ -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. diff --git a/packages/api/openapi/components/schemas.yaml b/packages/api/openapi/components/schemas.yaml index 8ab8819..98c2009 100644 --- a/packages/api/openapi/components/schemas.yaml +++ b/packages/api/openapi/components/schemas.yaml @@ -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. diff --git a/packages/api/openapi/components/security.yaml b/packages/api/openapi/components/security.yaml index 6b16271..ed66f35 100644 --- a/packages/api/openapi/components/security.yaml +++ b/packages/api/openapi/components/security.yaml @@ -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. diff --git a/packages/api/openapi/openapi.yaml b/packages/api/openapi/openapi.yaml index e070c33..4e9d19d 100644 --- a/packages/api/openapi/openapi.yaml +++ b/packages/api/openapi/openapi.yaml @@ -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: [] diff --git a/packages/api/openapi/paths/approval-policies-id.yaml b/packages/api/openapi/paths/approval-policies-id.yaml new file mode 100644 index 0000000..e0378f8 --- /dev/null +++ b/packages/api/openapi/paths/approval-policies-id.yaml @@ -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 diff --git a/packages/api/openapi/paths/approval-policies.yaml b/packages/api/openapi/paths/approval-policies.yaml new file mode 100644 index 0000000..bac3711 --- /dev/null +++ b/packages/api/openapi/paths/approval-policies.yaml @@ -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 diff --git a/packages/api/openapi/paths/approval-steps-id-decisions.yaml b/packages/api/openapi/paths/approval-steps-id-decisions.yaml new file mode 100644 index 0000000..457a467 --- /dev/null +++ b/packages/api/openapi/paths/approval-steps-id-decisions.yaml @@ -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 diff --git a/packages/api/openapi/paths/auth-callback.yaml b/packages/api/openapi/paths/auth-callback.yaml new file mode 100644 index 0000000..b8566a8 --- /dev/null +++ b/packages/api/openapi/paths/auth-callback.yaml @@ -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. diff --git a/packages/api/openapi/paths/auth-login.yaml b/packages/api/openapi/paths/auth-login.yaml new file mode 100644 index 0000000..84f7c5f --- /dev/null +++ b/packages/api/openapi/paths/auth-login.yaml @@ -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 diff --git a/packages/api/openapi/paths/auth-logout.yaml b/packages/api/openapi/paths/auth-logout.yaml new file mode 100644 index 0000000..71b0b42 --- /dev/null +++ b/packages/api/openapi/paths/auth-logout.yaml @@ -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 diff --git a/packages/api/openapi/paths/auth-refresh.yaml b/packages/api/openapi/paths/auth-refresh.yaml new file mode 100644 index 0000000..4b8af3a --- /dev/null +++ b/packages/api/openapi/paths/auth-refresh.yaml @@ -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 diff --git a/packages/api/openapi/paths/departments-id.yaml b/packages/api/openapi/paths/departments-id.yaml new file mode 100644 index 0000000..b91b01b --- /dev/null +++ b/packages/api/openapi/paths/departments-id.yaml @@ -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 diff --git a/packages/api/openapi/paths/departments.yaml b/packages/api/openapi/paths/departments.yaml new file mode 100644 index 0000000..3355b2d --- /dev/null +++ b/packages/api/openapi/paths/departments.yaml @@ -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 diff --git a/packages/api/openapi/paths/documents-id-confirmations.yaml b/packages/api/openapi/paths/documents-id-confirmations.yaml new file mode 100644 index 0000000..046f263 --- /dev/null +++ b/packages/api/openapi/paths/documents-id-confirmations.yaml @@ -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 diff --git a/packages/api/openapi/paths/documents-id.yaml b/packages/api/openapi/paths/documents-id.yaml new file mode 100644 index 0000000..62e0bdd --- /dev/null +++ b/packages/api/openapi/paths/documents-id.yaml @@ -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 diff --git a/packages/api/openapi/paths/gl-accounts-id.yaml b/packages/api/openapi/paths/gl-accounts-id.yaml new file mode 100644 index 0000000..c49d96f --- /dev/null +++ b/packages/api/openapi/paths/gl-accounts-id.yaml @@ -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 diff --git a/packages/api/openapi/paths/gl-accounts.yaml b/packages/api/openapi/paths/gl-accounts.yaml new file mode 100644 index 0000000..8dfb21c --- /dev/null +++ b/packages/api/openapi/paths/gl-accounts.yaml @@ -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 diff --git a/packages/api/openapi/paths/health.yaml b/packages/api/openapi/paths/health.yaml index a53bd7c..5d08746 100644 --- a/packages/api/openapi/paths/health.yaml +++ b/packages/api/openapi/paths/health.yaml @@ -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 diff --git a/packages/api/openapi/paths/inbox.yaml b/packages/api/openapi/paths/inbox.yaml new file mode 100644 index 0000000..b6ae046 --- /dev/null +++ b/packages/api/openapi/paths/inbox.yaml @@ -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 diff --git a/packages/api/openapi/paths/invoices-id-activity-logs.yaml b/packages/api/openapi/paths/invoices-id-activity-logs.yaml new file mode 100644 index 0000000..6ddd237 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-activity-logs.yaml @@ -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 diff --git a/packages/api/openapi/paths/invoices-id-comments.yaml b/packages/api/openapi/paths/invoices-id-comments.yaml new file mode 100644 index 0000000..e8d4a36 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-comments.yaml @@ -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 diff --git a/packages/api/openapi/paths/invoices-id-documents.yaml b/packages/api/openapi/paths/invoices-id-documents.yaml new file mode 100644 index 0000000..039a40b --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-documents.yaml @@ -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 diff --git a/packages/api/openapi/paths/invoices-id-lines.yaml b/packages/api/openapi/paths/invoices-id-lines.yaml new file mode 100644 index 0000000..af59da5 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id-lines.yaml @@ -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 diff --git a/packages/api/openapi/paths/invoices-id.yaml b/packages/api/openapi/paths/invoices-id.yaml new file mode 100644 index 0000000..ca7de24 --- /dev/null +++ b/packages/api/openapi/paths/invoices-id.yaml @@ -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 diff --git a/packages/api/openapi/paths/invoices.yaml b/packages/api/openapi/paths/invoices.yaml new file mode 100644 index 0000000..1e45e53 --- /dev/null +++ b/packages/api/openapi/paths/invoices.yaml @@ -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 diff --git a/packages/api/openapi/paths/me.yaml b/packages/api/openapi/paths/me.yaml index f98682f..c8fe5d5 100644 --- a/packages/api/openapi/paths/me.yaml +++ b/packages/api/openapi/paths/me.yaml @@ -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 diff --git a/packages/api/openapi/paths/ready.yaml b/packages/api/openapi/paths/ready.yaml new file mode 100644 index 0000000..b64c207 --- /dev/null +++ b/packages/api/openapi/paths/ready.yaml @@ -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 diff --git a/packages/api/openapi/paths/users-id.yaml b/packages/api/openapi/paths/users-id.yaml new file mode 100644 index 0000000..0db0ea1 --- /dev/null +++ b/packages/api/openapi/paths/users-id.yaml @@ -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 diff --git a/packages/api/openapi/paths/users.yaml b/packages/api/openapi/paths/users.yaml new file mode 100644 index 0000000..1cd0665 --- /dev/null +++ b/packages/api/openapi/paths/users.yaml @@ -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 diff --git a/packages/api/openapi/paths/vendors-id.yaml b/packages/api/openapi/paths/vendors-id.yaml new file mode 100644 index 0000000..593afcd --- /dev/null +++ b/packages/api/openapi/paths/vendors-id.yaml @@ -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 diff --git a/packages/api/openapi/paths/vendors.yaml b/packages/api/openapi/paths/vendors.yaml new file mode 100644 index 0000000..4db6fd3 --- /dev/null +++ b/packages/api/openapi/paths/vendors.yaml @@ -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 diff --git a/packages/api/package.json b/packages/api/package.json index fc61bf9..9f7b478 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/app.test.ts b/packages/api/src/app.test.ts index 27e1de1..8e43314 100644 --- a/packages/api/src/app.test.ts +++ b/packages/api/src/app.test.ts @@ -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); }); }); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 340d45c..a4a95a7 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -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(); - const auth = createAuthMiddleware(env, handle); - - app.route("/", createHealthRoutes(handle)); + const auth = createAuthMiddleware(env, handle, deps); const api = new Hono(); + 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; diff --git a/packages/api/src/approvals.ts b/packages/api/src/approvals.ts new file mode 100644 index 0000000..89afeb6 --- /dev/null +++ b/packages/api/src/approvals.ts @@ -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 { + await appendActivity(handle, invoice.id, actorUserId, "Invoice created."); + const policies = (await rowsOf(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 { + const rows = await rowsOf(handle, approvalSteps); + return rows.filter((row) => row.invoiceId === invoiceId && row.status === "pending"); +} + +export async function closePendingSteps( + handle: DbHandle, + invoiceId: string, + actorUserId: string | null, +): Promise { + 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(); + } +} diff --git a/packages/api/src/auth/cognito.ts b/packages/api/src/auth/cognito.ts new file mode 100644 index 0000000..361bece --- /dev/null +++ b/packages/api/src/auth/cognito.ts @@ -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; + refresh(refreshToken: string): Promise; + revoke(refreshToken: string): Promise; +}; + +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, +): Promise { + 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, + }; +} diff --git a/packages/api/src/auth/cookies.test.ts b/packages/api/src/auth/cookies.test.ts new file mode 100644 index 0000000..c237ef9 --- /dev/null +++ b/packages/api/src/auth/cookies.test.ts @@ -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); + }); +}); diff --git a/packages/api/src/auth/cookies.ts b/packages/api/src/auth/cookies.ts new file mode 100644 index 0000000..e04d460 --- /dev/null +++ b/packages/api/src/auth/cookies.ts @@ -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; +}; + +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 { + const parsed: Record = {}; + 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; + 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); +} diff --git a/packages/api/src/auth/middleware.ts b/packages/api/src/auth/middleware.ts index a832bcf..f5707aa 100644 --- a/packages/api/src/auth/middleware.ts +++ b/packages/api/src/auth/middleware.ts @@ -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, fallback: UserRole): UserRole { +export type TokenVerifier = (token: string) => Promise; + +export type AuthDeps = { + verifyToken?: TokenVerifier; +}; + +function roleFromClaims(claims: Record): 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(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, "viewer"), + role: roleFromClaims(payload as Record), }); } catch (error) { if (error instanceof IdentityConflictError) { - return c.json({ error: error.message }, 409); + return errorJson(c, 409, "CONFLICT", error.message); } throw error; } diff --git a/packages/api/src/auth/oauth.test.ts b/packages/api/src/auth/oauth.test.ts new file mode 100644 index 0000000..3b18fa9 --- /dev/null +++ b/packages/api/src/auth/oauth.test.ts @@ -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"); + }); +}); diff --git a/packages/api/src/auth/oauth.ts b/packages/api/src/auth/oauth.ts new file mode 100644 index 0000000..09105eb --- /dev/null +++ b/packages/api/src/auth/oauth.ts @@ -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): 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): Set { + const origins = new Set(); + 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, +): 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); +} diff --git a/packages/api/src/auth/origin-verify.ts b/packages/api/src/auth/origin-verify.ts new file mode 100644 index 0000000..dfa5516 --- /dev/null +++ b/packages/api/src/auth/origin-verify.ts @@ -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); +} diff --git a/packages/api/src/auth/pkce.ts b/packages/api/src/auth/pkce.ts new file mode 100644 index 0000000..a63056d --- /dev/null +++ b/packages/api/src/auth/pkce.ts @@ -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; +} diff --git a/packages/api/src/auth/upsert-user.test.ts b/packages/api/src/auth/upsert-user.test.ts index 8eceaa8..882a313 100644 --- a/packages/api/src/auth/upsert-user.test.ts +++ b/packages/api/src/auth/upsert-user.test.ts @@ -5,7 +5,7 @@ import { IdentityConflictError, upsertUserFromIdentity } from "./upsert-user.js" function createDb(options: { bySub?: Record | null; byEmail?: Record | null; -}): Db { +}): { handle: Db; setSpy: ReturnType } { 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) => ({ + 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", diff --git a/packages/api/src/auth/upsert-user.ts b/packages/api/src/auth/upsert-user.ts index d0373a5..fdf3201 100644 --- a/packages/api/src/auth/upsert-user.ts +++ b/packages/api/src/auth/upsert-user.ts @@ -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(); diff --git a/packages/api/src/build-info.ts b/packages/api/src/build-info.ts new file mode 100644 index 0000000..408b803 --- /dev/null +++ b/packages/api/src/build-info.ts @@ -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() || ""; diff --git a/packages/api/src/db/client.ts b/packages/api/src/db/client.ts index cddde03..12195e9 100644 --- a/packages/api/src/db/client.ts +++ b/packages/api/src/db/client.ts @@ -10,6 +10,17 @@ export type PostgresDb = ReturnType; export type DataApiDb = ReturnType; export type Db = PostgresDb | DataApiDb; +type PostgresTx = Parameters[0]>[0]; +type DataApiTx = Parameters[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 { diff --git a/packages/api/src/db/migrate.ts b/packages/api/src/db/migrate.ts index 1506164..26fe890 100644 --- a/packages/api/src/db/migrate.ts +++ b/packages/api/src/db/migrate.ts @@ -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 { - 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."); diff --git a/packages/api/src/documents.ts b/packages/api/src/documents.ts new file mode 100644 index 0000000..0ad0b99 --- /dev/null +++ b/packages/api/src/documents.ts @@ -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; +}; + +export type DocumentsStore = { + presignPut(input: { objectKey: string; contentType: string }): Promise; + objectExists(objectKey: string): Promise; + presignGet(objectKey: string): Promise; +}; + +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 } { + const uploaded = new Set(); + 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`; + }, + }; +} diff --git a/packages/api/src/env.test.ts b/packages/api/src/env.test.ts index 26d642c..8b0350d 100644 --- a/packages/api/src/env.test.ts +++ b/packages/api/src/env.test.ts @@ -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({ diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 05d0ee2..12969b3 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -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", diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts new file mode 100644 index 0000000..bee88b8 --- /dev/null +++ b/packages/api/src/http.ts @@ -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); +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 9f54b4f..74f214d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -8,8 +8,8 @@ async function main(): Promise { 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 () => { diff --git a/packages/api/src/routes/approvals.test.ts b/packages/api/src/routes/approvals.test.ts new file mode 100644 index 0000000..eafb6d8 --- /dev/null +++ b/packages/api/src/routes/approvals.test.ts @@ -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 }); + }); +}); diff --git a/packages/api/src/routes/approvals.ts b/packages/api/src/routes/approvals.ts new file mode 100644 index 0000000..cd48c7d --- /dev/null +++ b/packages/api/src/routes/approvals.ts @@ -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(); + + routes.get("/approval-policies", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await rowsOf(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(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(handle, approvalPolicies, id); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "Policy not found."); + const body = await parseJsonBody(c); + const patch: Partial & { 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(handle, approvalSteps); + const invoiceRows = await rowsOf(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(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(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(handle, invoices, id); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const rows = (await rowsOf(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(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(handle, invoices, id); + if (!invoice) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const rows = (await rowsOf(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; +} diff --git a/packages/api/src/routes/auth.ts b/packages/api/src/routes/auth.ts new file mode 100644 index 0000000..67196be --- /dev/null +++ b/packages/api/src/routes/auth.ts @@ -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(); + + 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; +} diff --git a/packages/api/src/routes/departments.ts b/packages/api/src/routes/departments.ts new file mode 100644 index 0000000..d46a625 --- /dev/null +++ b/packages/api/src/routes/departments.ts @@ -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(); + + 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 = { 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; +} diff --git a/packages/api/src/routes/gl-accounts.ts b/packages/api/src/routes/gl-accounts.ts new file mode 100644 index 0000000..b889d65 --- /dev/null +++ b/packages/api/src/routes/gl-accounts.ts @@ -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(); + + 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 = { 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; +} diff --git a/packages/api/src/routes/health.ts b/packages/api/src/routes/health.ts index 0aec002..3b75e67 100644 --- a/packages/api/src/routes/health.ts +++ b/packages/api/src/routes/health.ts @@ -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."); } }); diff --git a/packages/api/src/routes/helpers.ts b/packages/api/src/routes/helpers.ts new file mode 100644 index 0000000..ef75037 --- /dev/null +++ b/packages/api/src/routes/helpers.ts @@ -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, 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) { + 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> { + try { + return await c.req.json>(); + } 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(handle: DbHandle, table: unknown): Promise { + return (await handle.db.select().from(table as never)) as T[]; +} + +export async function firstById( + handle: DbHandle, + table: unknown, + id: string, +): Promise { + const rows = await rowsOf(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 }; diff --git a/packages/api/src/routes/invoices.test.ts b/packages/api/src/routes/invoices.test.ts new file mode 100644 index 0000000..aab0993 --- /dev/null +++ b/packages/api/src/routes/invoices.test.ts @@ -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 }; + 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 }; + 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"); + }); +}); diff --git a/packages/api/src/routes/invoices.ts b/packages/api/src/routes/invoices.ts new file mode 100644 index 0000000..4f54d5d --- /dev/null +++ b/packages/api/src/routes/invoices.ts @@ -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; 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): 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 { + const rows = await rowsOf(handle, invoiceLines); + return rows.filter((row) => row.invoiceId === invoiceId); +} + +async function activeDuplicate( + handle: DbHandle, + vendorId: string, + invoiceNumber: string, + exceptId?: string, +): Promise { + const rows = await rowsOf(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(); + + routes.get("/invoices", async (c) => { + const denied = requireCan(c, "read:invoices"); + if (denied) return denied; + const rows = await rowsOf(handle, invoices); + const allLines = await rowsOf(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(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(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); + 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(handle, invoices, id); + if (!existing) return errorJson(c, 404, "NOT_FOUND", "Invoice not found."); + const body = await parseJsonBody(c); + const patch: Partial = { 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(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(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); + 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(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(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(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; +} diff --git a/packages/api/src/routes/master-data.test.ts b/packages/api/src/routes/master-data.test.ts new file mode 100644 index 0000000..94fa80e --- /dev/null +++ b/packages/api/src/routes/master-data.test.ts @@ -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" }); + }); +}); diff --git a/packages/api/src/routes/me.ts b/packages/api/src/routes/me.ts index e96b02f..1e98f08 100644 --- a/packages/api/src/routes/me.ts +++ b/packages/api/src/routes/me.ts @@ -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(); @@ -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({ diff --git a/packages/api/src/routes/users.ts b/packages/api/src/routes/users.ts new file mode 100644 index 0000000..7e2e1a9 --- /dev/null +++ b/packages/api/src/routes/users.ts @@ -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(); + + 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; +} diff --git a/packages/api/src/routes/vendors.ts b/packages/api/src/routes/vendors.ts new file mode 100644 index 0000000..8d819ff --- /dev/null +++ b/packages/api/src/routes/vendors.ts @@ -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(); + + 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 = { 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; +} diff --git a/packages/api/src/test/fake-db.ts b/packages/api/src/test/fake-db.ts new file mode 100644 index 0000000..1e5a843 --- /dev/null +++ b/packages/api/src/test/fake-db.ts @@ -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; + vendors: Array; + glAccounts: Array; + departments: Array; + invoices: Array; + invoiceLines: Array; + documents: Array; + approvalPolicies: Array; + approvalSteps: Array; + invoiceComments: Array; + activityLog: Array; +}; + +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> { + 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>, 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>; + 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) => ({ + 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) => ({ + 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) => { + const snapshot = structuredClone(store) as Store; + try { + return await callback(db as never); + } catch (error) { + (Object.keys(store) as Array).forEach((key) => { + const rows = store[key] as unknown as Array>; + rows.splice( + 0, + rows.length, + ...(snapshot[key] as unknown as Array>), + ); + }); + throw error; + } + }), + }; + + return { + driver: "postgres", + pool: { end: vi.fn(async () => undefined) } as never, + db: db as never, + }; +} + +export { findById }; diff --git a/packages/api/tsconfig.build.json b/packages/api/tsconfig.build.json index cf0eba4..fc94193 100644 --- a/packages/api/tsconfig.build.json +++ b/packages/api/tsconfig.build.json @@ -3,5 +3,5 @@ "compilerOptions": { "noEmit": false }, - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/test/**"] } diff --git a/redocly.yaml b/redocly.yaml index 4c640d9..6b2c306 100644 --- a/redocly.yaml +++ b/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: diff --git a/src/api/client.test.ts b/src/api/client.test.ts new file mode 100644 index 0000000..f35b812 --- /dev/null +++ b/src/api/client.test.ts @@ -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"); + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..6467cda --- /dev/null +++ b/src/api/client.ts @@ -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 | null = null; + +async function refreshSession(): Promise { + 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 { + 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(response: Response): Promise { + const text = await response.text(); + let body: T & Partial; + try { + body = JSON.parse(text) as T & Partial; + } 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; +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..e5b36b0 --- /dev/null +++ b/src/api/types.ts @@ -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; + 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 }> } }; + }; +};