From 9d3646876e86fd60b7e19c044ade60ed36cab17c Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:10:49 -0400 Subject: [PATCH] feat(api): stand up Hono Drizzle foundation with auth and Redocly (AP-14) (#12) * feat(api): stand up Hono Drizzle foundation with auth and Redocly * fix(api): bump drizzle-orm and hono node-server past audit highs * fix(api): harden auth upsert and Cognito token verification --- .env.example | 28 + .npmrc | 1 + .prettierignore | 2 + .redocly.lint-ignore.yaml | 5 + README.md | 38 +- docker-compose.yml | 49 + eslint.config.js | 2 +- package-lock.json | 1767 +++++++++++++++++ package.json | 15 +- packages/api/docs/auth.md | 37 + packages/api/docs/index.md | 6 + packages/api/docs/local-dev.md | 38 + packages/api/drizzle.config.ts | 10 + .../drizzle/0000_cheerful_peter_parker.sql | 158 ++ packages/api/drizzle/meta/0000_snapshot.json | 1150 +++++++++++ packages/api/drizzle/meta/_journal.json | 13 + packages/api/openapi/components/schemas.yaml | 54 + packages/api/openapi/components/security.yaml | 5 + packages/api/openapi/openapi.yaml | 31 + packages/api/openapi/paths/health.yaml | 33 + packages/api/openapi/paths/me.yaml | 44 + packages/api/package.json | 53 + packages/api/src/app.test.ts | 109 + packages/api/src/app.ts | 28 + packages/api/src/auth/middleware.ts | 136 ++ packages/api/src/auth/rbac.test.ts | 30 + packages/api/src/auth/rbac.ts | 41 + packages/api/src/auth/upsert-user.test.ts | 90 + packages/api/src/auth/upsert-user.ts | 91 + packages/api/src/db/client.ts | 51 + packages/api/src/db/migrate.ts | 33 + packages/api/src/db/schema/enums.test.ts | 18 + packages/api/src/db/schema/index.ts | 198 ++ packages/api/src/db/seed.ts | 161 ++ packages/api/src/env.test.ts | 57 + packages/api/src/env.ts | 81 + packages/api/src/index.ts | 32 + packages/api/src/routes/health.ts | 18 + packages/api/src/routes/me.ts | 23 + packages/api/tsconfig.build.json | 7 + packages/api/tsconfig.json | 21 + packages/api/vitest.config.ts | 8 + redocly.yaml | 130 ++ vite.config.ts | 6 + 44 files changed, 4902 insertions(+), 6 deletions(-) create mode 100644 .npmrc create mode 100644 .redocly.lint-ignore.yaml create mode 100644 docker-compose.yml create mode 100644 packages/api/docs/auth.md create mode 100644 packages/api/docs/index.md create mode 100644 packages/api/docs/local-dev.md create mode 100644 packages/api/drizzle.config.ts create mode 100644 packages/api/drizzle/0000_cheerful_peter_parker.sql create mode 100644 packages/api/drizzle/meta/0000_snapshot.json create mode 100644 packages/api/drizzle/meta/_journal.json create mode 100644 packages/api/openapi/components/schemas.yaml create mode 100644 packages/api/openapi/components/security.yaml create mode 100644 packages/api/openapi/openapi.yaml create mode 100644 packages/api/openapi/paths/health.yaml create mode 100644 packages/api/openapi/paths/me.yaml create mode 100644 packages/api/package.json create mode 100644 packages/api/src/app.test.ts create mode 100644 packages/api/src/app.ts create mode 100644 packages/api/src/auth/middleware.ts create mode 100644 packages/api/src/auth/rbac.test.ts create mode 100644 packages/api/src/auth/rbac.ts create mode 100644 packages/api/src/auth/upsert-user.test.ts create mode 100644 packages/api/src/auth/upsert-user.ts create mode 100644 packages/api/src/db/client.ts create mode 100644 packages/api/src/db/migrate.ts create mode 100644 packages/api/src/db/schema/enums.test.ts create mode 100644 packages/api/src/db/schema/index.ts create mode 100644 packages/api/src/db/seed.ts create mode 100644 packages/api/src/env.test.ts create mode 100644 packages/api/src/env.ts create mode 100644 packages/api/src/index.ts create mode 100644 packages/api/src/routes/health.ts create mode 100644 packages/api/src/routes/me.ts create mode 100644 packages/api/tsconfig.build.json create mode 100644 packages/api/tsconfig.json create mode 100644 packages/api/vitest.config.ts create mode 100644 redocly.yaml diff --git a/.env.example b/.env.example index 2cec3c4..6d07ee7 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,31 @@ VITE_USE_MOCKS=true # AP-22 visual parity uses Stampli wordmark when false/unset. # AP-38 Sea Haven branding cutover: set true. VITE_SEA_HAVEN_BRAND=false + +# --- @seahaven-ap/api (AP-14) --- +API_PORT=8787 +DATABASE_DRIVER=postgres +DATABASE_URL=postgresql://seahaven:seahaven@127.0.0.1:5432/seahaven_ap + +# Local-only auth bypass. Allowed only when NODE_ENV is development or test. +DEV_AUTH_BYPASS=true +DEV_AUTH_SUB=seed-sub-admin +DEV_AUTH_EMAIL=admin@seahavenind.com +DEV_AUTH_NAME=Dev Admin +DEV_AUTH_ROLE=admin + +# Cognito (required when DEV_AUTH_BYPASS=false) +# COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/ +# COGNITO_AUDIENCE= + +# Aurora Data API driver (DATABASE_DRIVER=data-api) +# AWS_REGION=us-east-1 +# RDS_CLUSTER_ARN= +# RDS_SECRET_ARN= +# RDS_DATABASE=seahaven_ap + +# MinIO (docker compose) — used by AP-15 document uploads +MINIO_ENDPOINT=http://127.0.0.1:9000 +MINIO_ACCESS_KEY=seahaven +MINIO_SECRET_KEY=seahavensecret +MINIO_BUCKET=seahaven-ap-documents diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..c7d3517 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +install-links=true diff --git a/.prettierignore b/.prettierignore index 8d4ad70..68a3995 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,5 @@ package-lock.json src/router.ts playwright-report test-results +packages/api/drizzle +.redocly.lint-ignore.yaml diff --git a/.redocly.lint-ignore.yaml b/.redocly.lint-ignore.yaml new file mode 100644 index 0000000..871a222 --- /dev/null +++ b/.redocly.lint-ignore.yaml @@ -0,0 +1,5 @@ +# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API. +# See https://redocly.com/docs/cli/ for more information. +# +# Intentionally empty for AP-14 foundation. Add justified ignores in the same +# style as Sea-Haven-Industries/procurement-ingest when needed. diff --git a/README.md b/README.md index e48e9ce..3ad12bc 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,51 @@ npm workspaces: - `@seahaven-ap/web` — Vite/React SPA (repo root) - `@seahaven-ap/shared` — payment ladder, invoice helpers, pay-date parsers, CSV constants ([`packages/shared`](packages/shared)) -- `@seahaven-ap/api` — reserved for AP-14 (not present yet) +- `@seahaven-ap/api` — Hono API, Drizzle schema, auth/RBAC ([`packages/api`](packages/api)) ## Local development +### Frontend (mocks) + ```bash npm ci cp .env.example .env # optional; defaults already use mocks npm run dev ``` -App serves at http://localhost:3000. `VITE_USE_MOCKS=true` is the default data path until the API is wired (`src/mocks`). +App serves at http://localhost:3000. `VITE_USE_MOCKS=true` is the default data path until AP-15 wires live API calls. + +### API + data plane (AP-14) + +```bash +docker compose up -d +cp .env.example .env +npm run db:migrate +npm run db:seed +npm run dev:api +``` + +API listens on http://127.0.0.1:8787. Vite proxies `/api` to that port. + +Smoke: + +```bash +curl -s http://127.0.0.1:8787/health +curl -s http://127.0.0.1:8787/api/me +``` + +`DEV_AUTH_BYPASS=true` is local-only and only allowed when `NODE_ENV` is `development` or `test` (rejected for production, staging, preview, and any other value). + +API roles (source of truth): `admin`, `ap_processor`, `approver`, `viewer`. Frontend mocks still use `ap_operator` until AP-15 remaps them. + +### OpenAPI / Redocly + +Linting uses the same `redocly.yaml` ruleset as `procurement-ingest`. + +```bash +npm run lint:api +npm run docs:preview # builds HTML via redocly build-docs and opens it +``` ## Verify diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..54c7102 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +services: + postgres: + image: postgres:16-alpine + ports: + - "5432:5432" + environment: + POSTGRES_USER: seahaven + POSTGRES_PASSWORD: seahaven + POSTGRES_DB: seahaven_ap + volumes: + - seahaven_ap_pg:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U seahaven -d seahaven_ap"] + interval: 5s + timeout: 5s + retries: 10 + + minio: + image: minio/minio:RELEASE.2025-04-22T22-12-26Z + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: seahaven + MINIO_ROOT_PASSWORD: seahavensecret + volumes: + - seahaven_ap_minio:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 10 + + minio-init: + image: minio/mc:RELEASE.2025-04-16T18-13-26Z + depends_on: + minio: + condition: service_started + entrypoint: > + /bin/sh -c " + until mc alias set local http://minio:9000 seahaven seahavensecret; do sleep 1; done; + mc mb --ignore-existing local/seahaven-ap-documents; + exit 0; + " + +volumes: + seahaven_ap_pg: + seahaven_ap_minio: diff --git a/eslint.config.js b/eslint.config.js index 78339bd..8eda1b2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,7 +20,7 @@ export default tseslint.config( ...tseslint.configs.recommended, eslintConfigPrettier, { - files: ["packages/shared/src/**/*.ts"], + files: ["packages/shared/src/**/*.ts", "packages/api/src/**/*.ts"], languageOptions: { ecmaVersion: "latest", sourceType: "module", diff --git a/package-lock.json b/package-lock.json index 4a1f161..732ca3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@playwright/test": "^1.55.0", + "@redocly/cli": "^2.44.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.0", "@types/node": "^24.3.0", @@ -80,6 +81,278 @@ "dev": true, "license": "ISC" }, + "node_modules/@aws-sdk/client-rds-data": { + "version": "3.1107.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-rds-data/-/client-rds-data-3.1107.0.tgz", + "integrity": "sha512-hLWQLDomwIRKLut2yVHOA/eq3p+Ra6QROa1LW3sgTCTkf5OW5iUrqWTP9kS/ktRZqeG+HtG48VOVUrux12gQGA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz", + "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz", + "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz", + "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz", + "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-login": "^3.972.74", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz", + "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz", + "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-ini": "^3.973.12", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz", + "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz", + "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/token-providers": "3.1103.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz", + "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", + "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", + "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1103.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz", + "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -483,6 +756,13 @@ "node": ">=18" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -629,6 +909,442 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1173,6 +1889,23 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@faker-js/faker": { + "version": "9.9.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.9.0.tgz", + "integrity": "sha512-OEl393iCOoo/z8bMezRlJu+GlRGlsKbUAN7jKB6LhnKoqKve5DXRpalbItIIcwnCjs1k/FOPjFzcA6Qn+H+YbA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + }, "node_modules/@fontsource/ibm-plex-sans": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", @@ -1206,6 +1939,18 @@ "vite": ">=5" } }, + "node_modules/@hono/node-server": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", + "integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1713,6 +2458,21 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@redocly/cli": { + "version": "2.46.0", + "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-2.46.0.tgz", + "integrity": "sha512-Hx4dIYwFhMkE6fZXDl0TwuVsvE8INX7dzEOgAcEhl0mhTkHC95o/rhUTlVLbNRg4mWHQ27jbdgKXPZBJJANWYA==", + "dev": true, + "license": "MIT", + "bin": { + "openapi": "bin/cli.js", + "redocly": "bin/cli.js" + }, + "engines": { + "node": ">=22.12.0 || >=20.19.0 <21.0.0", + "npm": ">=10" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -2084,10 +2844,95 @@ "win32" ] }, + "node_modules/@seahaven-ap/api": { + "resolved": "packages/api", + "link": true + }, "node_modules/@seahaven-ap/shared": { "resolved": "packages/shared", "link": true }, + "node_modules/@smithy/core": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@tanstack/query-core": { "version": "5.101.4", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", @@ -2327,6 +3172,18 @@ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -2859,6 +3716,12 @@ "node": ">=6.0.0" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -2918,6 +3781,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -3156,6 +4026,631 @@ "csstype": "^3.0.2" } }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/electron-to-chromium": { "version": "1.5.403", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.403.tgz", @@ -3654,6 +5149,19 @@ "node": ">=6.9.0" } }, + "node_modules/get-tsconfig": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.1.tgz", + "integrity": "sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3724,6 +5232,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/hono": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -3889,6 +5406,15 @@ "dev": true, "license": "ISC" }, + "node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4345,6 +5871,95 @@ "node": ">= 14.16" } }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4423,6 +6038,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4658,6 +6312,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4827,6 +6491,36 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5059,6 +6753,45 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5536,6 +7269,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -5579,6 +7321,31 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "packages/api": { + "name": "@seahaven-ap/api", + "version": "0.1.0", + "dependencies": { + "@aws-sdk/client-rds-data": "^3.848.0", + "@hono/node-server": "^2.1.0", + "@seahaven-ap/shared": "*", + "drizzle-orm": "^0.45.2", + "hono": "^4.13.1", + "jose": "^6.0.11", + "pg": "^8.16.3" + }, + "devDependencies": { + "@faker-js/faker": "^9.9.0", + "@types/node": "^24.3.0", + "@types/pg": "^8.15.4", + "drizzle-kit": "^0.31.10", + "tsx": "^4.20.3", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22.22.1" + } + }, "packages/shared": { "name": "@seahaven-ap/shared", "version": "0.1.0", diff --git a/package.json b/package.json index 5ef0b3f..6a1d4d4 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,23 @@ ], "scripts": { "dev": "npm run build:shared && vite", + "dev:api": "npm run build:shared && npm run dev -w @seahaven-ap/api", "generate:router": "node scripts/generate-router.mjs", "build:shared": "npm run build -w @seahaven-ap/shared", - "build": "npm run build:shared && npm run generate:router && tsc -b && vite build", + "build:api": "npm run build:shared && npm run build -w @seahaven-ap/api", + "build": "npm run build:shared && npm run build:api && npm run generate:router && tsc -b && vite build", "preview": "vite preview", - "test": "npm run test -w @seahaven-ap/shared && vitest run", + "db:migrate": "npm run db:migrate -w @seahaven-ap/api", + "db:seed": "npm run db:seed -w @seahaven-ap/api", + "db:generate": "npm run db:generate -w @seahaven-ap/api", + "test": "npm run test -w @seahaven-ap/shared && npm run test -w @seahaven-ap/api && vitest run", "test:watch": "vitest", "test:e2e": "playwright test", - "lint": "eslint . --max-warnings=0", + "lint": "eslint . --max-warnings=0 && npm run lint:api", + "lint:api": "redocly lint packages/api/openapi/openapi.yaml --config=redocly.yaml", "lint:fix": "eslint . --fix --max-warnings=0", + "docs:build": "redocly build-docs packages/api/openapi/openapi.yaml --config=redocly.yaml -o /tmp/seahaven-ap-api-docs.html", + "docs:preview": "npm run docs:build && open /tmp/seahaven-ap-api-docs.html", "format": "prettier --write .", "format:check": "prettier --check .", "verify": "npm run format:check && npm run lint && npm run build && npm test" @@ -45,6 +53,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@playwright/test": "^1.55.0", + "@redocly/cli": "^2.44.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.0", "@types/node": "^24.3.0", diff --git a/packages/api/docs/auth.md b/packages/api/docs/auth.md new file mode 100644 index 0000000..9d3e01c --- /dev/null +++ b/packages/api/docs/auth.md @@ -0,0 +1,37 @@ +# Authentication + +## Cognito bearer tokens + +Production and seahaven-dev expect a Bearer Cognito token verified against the +user pool JWKS and issuer. + +Audience check: + +- ID tokens (`token_use=id`): `aud` must equal `COGNITO_AUDIENCE` (app client id). +- Access tokens (`token_use=access`): `client_id` must equal `COGNITO_AUDIENCE`. + +Identity claims (`sub`, `email`, and `name` or `cognito:username`) are required. +Prefer a Cognito **ID token**, which carries email/name by default. An access +token is accepted only when it includes an `email` claim (for example via a +pre-token-generation enrichment). + +Optional role claim mapping: + +- `custom:role` or `role` → `role` (`admin`, `ap_processor`, `approver`, `viewer`) +- Missing role defaults to `viewer` + +Users are upserted by `sub` only. An email already linked to a different `sub` +returns HTTP 409 and does not rebind the account. + +## Local DEV_AUTH_BYPASS + +For local development only, set `DEV_AUTH_BYPASS=true`. The middleware uses +`DEV_AUTH_SUB`, `DEV_AUTH_EMAIL`, `DEV_AUTH_NAME`, and `DEV_AUTH_ROLE` and +skips JWT verification. + +Align `DEV_AUTH_SUB` with the seeded Cognito subject (default `seed-sub-admin`) +so local auth updates the seed user instead of conflicting on email. + +`DEV_AUTH_BYPASS` is allowed only when `NODE_ENV` is `development` or `test`. +Any other value (including `production`, `staging`, and `preview`) rejects +startup. diff --git a/packages/api/docs/index.md b/packages/api/docs/index.md new file mode 100644 index 0000000..ca1808a --- /dev/null +++ b/packages/api/docs/index.md @@ -0,0 +1,6 @@ +# Sea Haven AP API + +HTTP API for Sea Haven accounts payable (`ap.seahaven.com`). + +This foundation documents the health and session smoke surface introduced in +AP-14. Domain CRUD lands in later tickets and extends this OpenAPI tree. diff --git a/packages/api/docs/local-dev.md b/packages/api/docs/local-dev.md new file mode 100644 index 0000000..7b02dcd --- /dev/null +++ b/packages/api/docs/local-dev.md @@ -0,0 +1,38 @@ +# Local development + +## Data plane + +```bash +docker compose up -d +npm run db:migrate +npm run db:seed +npm run dev:api +``` + +Defaults: + +- Postgres at `postgresql://seahaven:seahaven@127.0.0.1:5432/seahaven_ap` +- API at `http://127.0.0.1:8787` +- MinIO at `http://127.0.0.1:9000` (documents bucket for AP-15) + +## Smoke + +```bash +curl -s http://127.0.0.1:8787/health +curl -s http://127.0.0.1:8787/api/me +``` + +With `DEV_AUTH_BYPASS=true` and `NODE_ENV=development` (or `test`), `/api/me` +does not require a Bearer token. Bypass is rejected for staging, preview, +production, and any other `NODE_ENV`. + +## Docs + +```bash +npm run lint:api +npm run docs:preview +``` + +`docs:preview` runs `redocly build-docs` (CLI v2) and opens the HTML at +`/tmp/seahaven-ap-api-docs.html`. Published OpenAPI servers point at +`https://ap.seahaven.com`. Use this page for the local docs view. diff --git a/packages/api/drizzle.config.ts b/packages/api/drizzle.config.ts new file mode 100644 index 0000000..4052747 --- /dev/null +++ b/packages/api/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/db/schema/index.ts", + out: "./drizzle", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL ?? "postgresql://seahaven:seahaven@127.0.0.1:5432/seahaven_ap", + }, +}); diff --git a/packages/api/drizzle/0000_cheerful_peter_parker.sql b/packages/api/drizzle/0000_cheerful_peter_parker.sql new file mode 100644 index 0000000..8b7345b --- /dev/null +++ b/packages/api/drizzle/0000_cheerful_peter_parker.sql @@ -0,0 +1,158 @@ +CREATE TYPE "public"."approval_status" AS ENUM('pending', 'approved', 'rejected', 'skipped');--> statement-breakpoint +CREATE TYPE "public"."invoice_status" AS ENUM('pending_approval', 'approved', 'scheduled', 'paid', 'rejected', 'void');--> statement-breakpoint +CREATE TYPE "public"."payment_method" AS ENUM('check', 'ach');--> statement-breakpoint +CREATE TYPE "public"."payment_status" AS ENUM('scheduled', 'payment_submitted', 'issued', 'outstanding', 'cleared');--> statement-breakpoint +CREATE TYPE "public"."user_role" AS ENUM('admin', 'ap_processor', 'approver', 'viewer');--> statement-breakpoint +CREATE TABLE "activity_log" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "invoice_id" uuid NOT NULL, + "actor_user_id" uuid, + "message" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "approval_policies" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "priority" integer DEFAULT 100 NOT NULL, + "amount_threshold" numeric(14, 2), + "skip_below_amount" numeric(14, 2), + "active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "approval_steps" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "invoice_id" uuid NOT NULL, + "policy_id" uuid, + "step_order" integer DEFAULT 1 NOT NULL, + "approver_role" "user_role" DEFAULT 'approver' NOT NULL, + "assignee_user_id" uuid, + "status" "approval_status" DEFAULT 'pending' NOT NULL, + "acted_by_user_id" uuid, + "acted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "departments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "code" text NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "documents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "invoice_id" uuid, + "object_key" text NOT NULL, + "content_type" text DEFAULT 'application/pdf' NOT NULL, + "file_name" text NOT NULL, + "uploaded_by_user_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "gl_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "code" text NOT NULL, + "name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "invoice_comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "invoice_id" uuid NOT NULL, + "author_user_id" uuid, + "body" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "invoice_lines" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "invoice_id" uuid NOT NULL, + "description" text NOT NULL, + "amount" numeric(14, 2) NOT NULL, + "gl_account_id" uuid, + "department_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "invoices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "vendor_id" uuid NOT NULL, + "invoice_number" text NOT NULL, + "amount" numeric(14, 2) NOT NULL, + "amount_due" numeric(14, 2) NOT NULL, + "due_date" text NOT NULL, + "pay_date" text, + "send_payment_on" text, + "status" "invoice_status" DEFAULT 'pending_approval' NOT NULL, + "payment_method" "payment_method" DEFAULT 'check' NOT NULL, + "memo" text DEFAULT '' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "pay_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_by_user_id" uuid, + "status" text DEFAULT 'draft' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "payments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "pay_run_id" uuid, + "invoice_id" uuid, + "vendor_id" uuid, + "amount" numeric(14, 2) NOT NULL, + "payment_method" "payment_method" DEFAULT 'check' NOT NULL, + "check_number" integer, + "status" "payment_status" DEFAULT 'scheduled' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "cognito_sub" text NOT NULL, + "email" text NOT NULL, + "name" text NOT NULL, + "role" "user_role" DEFAULT 'viewer' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "vendors" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "email" text, + "default_payment_method" "payment_method" DEFAULT 'check' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "approval_steps" ADD CONSTRAINT "approval_steps_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "approval_steps" ADD CONSTRAINT "approval_steps_policy_id_approval_policies_id_fk" FOREIGN KEY ("policy_id") REFERENCES "public"."approval_policies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "approval_steps" ADD CONSTRAINT "approval_steps_assignee_user_id_users_id_fk" FOREIGN KEY ("assignee_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "approval_steps" ADD CONSTRAINT "approval_steps_acted_by_user_id_users_id_fk" FOREIGN KEY ("acted_by_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "documents" ADD CONSTRAINT "documents_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "documents" ADD CONSTRAINT "documents_uploaded_by_user_id_users_id_fk" FOREIGN KEY ("uploaded_by_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invoice_comments" ADD CONSTRAINT "invoice_comments_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invoice_comments" ADD CONSTRAINT "invoice_comments_author_user_id_users_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invoice_lines" ADD CONSTRAINT "invoice_lines_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invoice_lines" ADD CONSTRAINT "invoice_lines_gl_account_id_gl_accounts_id_fk" FOREIGN KEY ("gl_account_id") REFERENCES "public"."gl_accounts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invoice_lines" ADD CONSTRAINT "invoice_lines_department_id_departments_id_fk" FOREIGN KEY ("department_id") REFERENCES "public"."departments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invoices" ADD CONSTRAINT "invoices_vendor_id_vendors_id_fk" FOREIGN KEY ("vendor_id") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "pay_runs" ADD CONSTRAINT "pay_runs_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payments" ADD CONSTRAINT "payments_pay_run_id_pay_runs_id_fk" FOREIGN KEY ("pay_run_id") REFERENCES "public"."pay_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payments" ADD CONSTRAINT "payments_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "payments" ADD CONSTRAINT "payments_vendor_id_vendors_id_fk" FOREIGN KEY ("vendor_id") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "invoices_vendor_number_active_uidx" ON "invoices" USING btree ("vendor_id","invoice_number") WHERE "invoices"."status" <> 'void';--> statement-breakpoint +CREATE UNIQUE INDEX "users_cognito_sub_uidx" ON "users" USING btree ("cognito_sub");--> statement-breakpoint +CREATE UNIQUE INDEX "users_email_uidx" ON "users" USING btree ("email"); \ No newline at end of file diff --git a/packages/api/drizzle/meta/0000_snapshot.json b/packages/api/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..583ccd5 --- /dev/null +++ b/packages/api/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1150 @@ +{ + "id": "5401741c-bdd3-4411-9508-279000503ce6", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "activity_log_invoice_id_invoices_id_fk": { + "name": "activity_log_invoice_id_invoices_id_fk", + "tableFrom": "activity_log", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "activity_log_actor_user_id_users_id_fk": { + "name": "activity_log_actor_user_id_users_id_fk", + "tableFrom": "activity_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_policies": { + "name": "approval_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "amount_threshold": { + "name": "amount_threshold", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "skip_below_amount": { + "name": "skip_below_amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_steps": { + "name": "approval_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "step_order": { + "name": "step_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "approver_role": { + "name": "approver_role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'approver'" + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "approval_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "acted_by_user_id": { + "name": "acted_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "acted_at": { + "name": "acted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "approval_steps_invoice_id_invoices_id_fk": { + "name": "approval_steps_invoice_id_invoices_id_fk", + "tableFrom": "approval_steps", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "approval_steps_policy_id_approval_policies_id_fk": { + "name": "approval_steps_policy_id_approval_policies_id_fk", + "tableFrom": "approval_steps", + "tableTo": "approval_policies", + "columnsFrom": [ + "policy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_steps_assignee_user_id_users_id_fk": { + "name": "approval_steps_assignee_user_id_users_id_fk", + "tableFrom": "approval_steps", + "tableTo": "users", + "columnsFrom": [ + "assignee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "approval_steps_acted_by_user_id_users_id_fk": { + "name": "approval_steps_acted_by_user_id_users_id_fk", + "tableFrom": "approval_steps", + "tableTo": "users", + "columnsFrom": [ + "acted_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.departments": { + "name": "departments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'application/pdf'" + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_user_id": { + "name": "uploaded_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "documents_invoice_id_invoices_id_fk": { + "name": "documents_invoice_id_invoices_id_fk", + "tableFrom": "documents", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "documents_uploaded_by_user_id_users_id_fk": { + "name": "documents_uploaded_by_user_id_users_id_fk", + "tableFrom": "documents", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gl_accounts": { + "name": "gl_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_comments": { + "name": "invoice_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_comments_invoice_id_invoices_id_fk": { + "name": "invoice_comments_invoice_id_invoices_id_fk", + "tableFrom": "invoice_comments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoice_comments_author_user_id_users_id_fk": { + "name": "invoice_comments_author_user_id_users_id_fk", + "tableFrom": "invoice_comments", + "tableTo": "users", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoice_lines": { + "name": "invoice_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "gl_account_id": { + "name": "gl_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invoice_lines_invoice_id_invoices_id_fk": { + "name": "invoice_lines_invoice_id_invoices_id_fk", + "tableFrom": "invoice_lines", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invoice_lines_gl_account_id_gl_accounts_id_fk": { + "name": "invoice_lines_gl_account_id_gl_accounts_id_fk", + "tableFrom": "invoice_lines", + "tableTo": "gl_accounts", + "columnsFrom": [ + "gl_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoice_lines_department_id_departments_id_fk": { + "name": "invoice_lines_department_id_departments_id_fk", + "tableFrom": "invoice_lines", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invoices": { + "name": "invoices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "vendor_id": { + "name": "vendor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "amount_due": { + "name": "amount_due", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pay_date": { + "name": "pay_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "send_payment_on": { + "name": "send_payment_on", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invoice_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'check'" + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invoices_vendor_number_active_uidx": { + "name": "invoices_vendor_number_active_uidx", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invoices\".\"status\" <> 'void'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invoices_vendor_id_vendors_id_fk": { + "name": "invoices_vendor_id_vendors_id_fk", + "tableFrom": "invoices", + "tableTo": "vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pay_runs": { + "name": "pay_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pay_runs_created_by_user_id_users_id_fk": { + "name": "pay_runs_created_by_user_id_users_id_fk", + "tableFrom": "pay_runs", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "pay_run_id": { + "name": "pay_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "vendor_id": { + "name": "vendor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric(14, 2)", + "primaryKey": false, + "notNull": true + }, + "payment_method": { + "name": "payment_method", + "type": "payment_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'check'" + }, + "check_number": { + "name": "check_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_pay_run_id_pay_runs_id_fk": { + "name": "payments_pay_run_id_pay_runs_id_fk", + "tableFrom": "payments", + "tableTo": "pay_runs", + "columnsFrom": [ + "pay_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payments_invoice_id_invoices_id_fk": { + "name": "payments_invoice_id_invoices_id_fk", + "tableFrom": "payments", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payments_vendor_id_vendors_id_fk": { + "name": "payments_vendor_id_vendors_id_fk", + "tableFrom": "payments", + "tableTo": "vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "cognito_sub": { + "name": "cognito_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_cognito_sub_uidx": { + "name": "users_cognito_sub_uidx", + "columns": [ + { + "expression": "cognito_sub", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_uidx": { + "name": "users_email_uidx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vendors": { + "name": "vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_payment_method": { + "name": "default_payment_method", + "type": "payment_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'check'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.approval_status": { + "name": "approval_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected", + "skipped" + ] + }, + "public.invoice_status": { + "name": "invoice_status", + "schema": "public", + "values": [ + "pending_approval", + "approved", + "scheduled", + "paid", + "rejected", + "void" + ] + }, + "public.payment_method": { + "name": "payment_method", + "schema": "public", + "values": [ + "check", + "ach" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "scheduled", + "payment_submitted", + "issued", + "outstanding", + "cleared" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "ap_processor", + "approver", + "viewer" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/api/drizzle/meta/_journal.json b/packages/api/drizzle/meta/_journal.json new file mode 100644 index 0000000..69dd2d4 --- /dev/null +++ b/packages/api/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1786404956920, + "tag": "0000_cheerful_peter_parker", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/api/openapi/components/schemas.yaml b/packages/api/openapi/components/schemas.yaml new file mode 100644 index 0000000..8ab8819 --- /dev/null +++ b/packages/api/openapi/components/schemas.yaml @@ -0,0 +1,54 @@ +Error: + type: object + required: + - error + properties: + error: + type: string + description: Human-readable error message. + example: Missing or invalid Authorization header. +HealthResponse: + type: object + required: + - status + - database + properties: + status: + type: string + description: Process health marker. + example: ok + database: + type: string + description: Database ping result. + example: up +MeResponse: + type: object + required: + - id + - email + - name + - role + properties: + id: + type: string + format: uuid + description: Internal user primary key. + example: 11111111-1111-4111-8111-111111111111 + email: + type: string + format: email + description: Caller email address. + example: admin@seahavenind.com + name: + type: string + description: Display name for the caller. + example: Dev Admin + role: + type: string + description: Authorization role for RBAC checks. + enum: + - admin + - ap_processor + - approver + - viewer + example: admin diff --git a/packages/api/openapi/components/security.yaml b/packages/api/openapi/components/security.yaml new file mode 100644 index 0000000..6b16271 --- /dev/null +++ b/packages/api/openapi/components/security.yaml @@ -0,0 +1,5 @@ +bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Cognito ID token preferred. Access tokens require an email claim. Local DEV_AUTH_BYPASS skips verification. diff --git a/packages/api/openapi/openapi.yaml b/packages/api/openapi/openapi.yaml new file mode 100644 index 0000000..e070c33 --- /dev/null +++ b/packages/api/openapi/openapi.yaml @@ -0,0 +1,31 @@ +openapi: 3.1.0 +info: + title: Sea Haven AP API + version: 1.0.0 + description: "Accounts payable HTTP API for Sea Haven Industries. Foundation surface for health and authenticated session smoke under local and AWS runtimes." + license: + name: Proprietary +servers: + - url: https://ap.seahaven.com + description: Production API host for ap.seahaven.com. +tags: + - name: Health + description: Liveness and dependency checks for the API process. + - name: Session + description: Authenticated caller identity after JWT or local dev auth. +paths: + /health: + $ref: ./paths/health.yaml + /api/me: + $ref: ./paths/me.yaml +components: + securitySchemes: + bearerAuth: + $ref: ./components/security.yaml#/bearerAuth + schemas: + Error: + $ref: ./components/schemas.yaml#/Error + HealthResponse: + $ref: ./components/schemas.yaml#/HealthResponse + MeResponse: + $ref: ./components/schemas.yaml#/MeResponse diff --git a/packages/api/openapi/paths/health.yaml b/packages/api/openapi/paths/health.yaml new file mode 100644 index 0000000..a53bd7c --- /dev/null +++ b/packages/api/openapi/paths/health.yaml @@ -0,0 +1,33 @@ +get: + tags: + - Health + summary: Check API and database liveness + description: Returns ok when the process can ping the configured database. + operationId: get-health + security: [] + responses: + "200": + description: API process is healthy and the database answered. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/HealthResponse + example: + status: ok + database: up + "400": + description: Bad request. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: Bad request. + "503": + description: Database ping failed. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: Database is unavailable. diff --git a/packages/api/openapi/paths/me.yaml b/packages/api/openapi/paths/me.yaml new file mode 100644 index 0000000..f98682f --- /dev/null +++ b/packages/api/openapi/paths/me.yaml @@ -0,0 +1,44 @@ +get: + tags: + - Session + summary: Return the authenticated caller profile + description: Upserts the caller into users on first request and returns the stored profile used by the SPA session smoke path. + operationId: get-api-me + security: + - bearerAuth: [] + responses: + "200": + description: Authenticated user profile. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/MeResponse + example: + id: 11111111-1111-4111-8111-111111111111 + email: admin@seahavenind.com + name: Dev Admin + role: admin + "401": + description: Missing or invalid bearer token. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: Missing or invalid Authorization header. + "409": + description: Email is already linked to a different Cognito subject. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: Email admin@seahavenind.com is already linked to a different identity. + "404": + description: Not found. + content: + application/json: + schema: + $ref: ../components/schemas.yaml#/Error + example: + error: Not found. diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 0000000..4a56c8f --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,53 @@ +{ + "name": "@seahaven-ap/api", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Sea Haven AP Hono API — Drizzle schema, auth/RBAC, local data plane", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist", + "drizzle", + "openapi", + "docs" + ], + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc --project tsconfig.build.json", + "typecheck": "tsc --noEmit --project tsconfig.json", + "start": "node dist/index.js", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx src/db/migrate.ts", + "db:seed": "tsx src/db/seed.ts", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@aws-sdk/client-rds-data": "^3.848.0", + "@hono/node-server": "^2.1.0", + "@seahaven-ap/shared": "*", + "drizzle-orm": "^0.45.2", + "hono": "^4.13.1", + "jose": "^6.0.11", + "pg": "^8.16.3" + }, + "devDependencies": { + "@faker-js/faker": "^9.9.0", + "@types/node": "^24.3.0", + "@types/pg": "^8.15.4", + "drizzle-kit": "^0.31.10", + "tsx": "^4.20.3", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22.22.1" + } +} diff --git a/packages/api/src/app.test.ts b/packages/api/src/app.test.ts new file mode 100644 index 0000000..27e1de1 --- /dev/null +++ b/packages/api/src/app.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { createApp } from "./app.js"; +import type { Db } from "./db/client.js"; +import { loadEnv } from "./env.js"; +import type { AuthUser } from "./auth/upsert-user.js"; + +const sampleUser: AuthUser = { + id: "11111111-1111-4111-8111-111111111111", + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + role: "admin", +}; + +function createTestDb(options?: { pingFails?: boolean }): Db { + const userRow = { + id: sampleUser.id, + cognitoSub: sampleUser.cognitoSub, + email: sampleUser.email, + name: sampleUser.name, + role: sampleUser.role, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const db = { + execute: vi.fn(async () => { + if (options?.pingFails) { + throw new Error("db down"); + } + return []; + }), + query: { + users: { + findFirst: vi.fn(async () => userRow), + }, + }, + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn(async () => [userRow]), + })), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn(async () => [userRow]), + })), + })), + }; + + return { + driver: "postgres", + pool: { end: vi.fn(async () => undefined) } as never, + db: db as never, + }; +} + +describe("createApp smoke routes", () => { + const env = loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + DEV_AUTH_SUB: sampleUser.cognitoSub, + DEV_AUTH_EMAIL: sampleUser.email, + DEV_AUTH_NAME: sampleUser.name, + DEV_AUTH_ROLE: sampleUser.role, + }); + + it("GET /health returns ok when the database pings", async () => { + const app = createApp(env, createTestDb()); + const response = await app.request("/health"); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ status: "ok", database: "up" }); + }); + + it("GET /health returns error payload when the database is down", async () => { + const app = createApp(env, createTestDb({ pingFails: true })); + const response = await app.request("/health"); + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ error: "Database is unavailable." }); + }); + + it("GET /api/me returns the upserted caller under DEV_AUTH_BYPASS", async () => { + const app = createApp(env, createTestDb()); + const response = await app.request("/api/me"); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + id: sampleUser.id, + email: sampleUser.email, + name: sampleUser.name, + role: sampleUser.role, + }); + }); + + it("GET /api/me rejects missing bearer token when bypass is off", async () => { + const secureEnv = loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "false", + COGNITO_ISSUER: "https://cognito-idp.us-east-1.amazonaws.com/test", + COGNITO_AUDIENCE: "test-audience", + }); + const app = createApp(secureEnv, createTestDb()); + const response = await app.request("/api/me"); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ + error: "Missing or invalid Authorization header.", + }); + }); +}); diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts new file mode 100644 index 0000000..340d45c --- /dev/null +++ b/packages/api/src/app.ts @@ -0,0 +1,28 @@ +import { Hono } from "hono"; +import type { ApiEnv } from "./env.js"; +import type { Db } from "./db/client.js"; +import { createAuthMiddleware, type AppBindings } from "./auth/middleware.js"; +import { createHealthRoutes } from "./routes/health.js"; +import { createMeRoutes } from "./routes/me.js"; + +export function createApp(env: ApiEnv, handle: Db) { + const app = new Hono(); + const auth = createAuthMiddleware(env, handle); + + app.route("/", createHealthRoutes(handle)); + + const api = new Hono(); + api.use("*", auth); + api.route("/", createMeRoutes()); + app.route("/api", api); + + app.notFound((c) => c.json({ error: "Not found." }, 404)); + app.onError((error, c) => { + console.error(error); + return c.json({ error: "Internal server error." }, 500); + }); + + return app; +} + +export type App = ReturnType; diff --git a/packages/api/src/auth/middleware.ts b/packages/api/src/auth/middleware.ts new file mode 100644 index 0000000..a832bcf --- /dev/null +++ b/packages/api/src/auth/middleware.ts @@ -0,0 +1,136 @@ +import { createMiddleware } from "hono/factory"; +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; +import type { AuthUser } from "./upsert-user.js"; +import { IdentityConflictError, upsertUserFromIdentity } from "./upsert-user.js"; +import type { ApiEnv, UserRole } from "../env.js"; +import { isUserRole } from "../env.js"; +import type { Db } from "../db/client.js"; + +export type AppVariables = { + user: AuthUser; +}; + +export type AppBindings = { + Variables: AppVariables; +}; + +function roleFromClaims(claims: Record, fallback: UserRole): UserRole { + const raw = + (typeof claims["custom:role"] === "string" && claims["custom:role"]) || + (typeof claims.role === "string" && claims.role) || + fallback; + return isUserRole(raw) ? raw : fallback; +} + +function audienceMatches(payload: JWTPayload, expected: string): boolean { + const claims = payload as JWTPayload & { token_use?: string; client_id?: string }; + if (claims.token_use === "access") { + return claims.client_id === expected; + } + + if (typeof payload.aud === "string") { + return payload.aud === expected; + } + if (Array.isArray(payload.aud)) { + return payload.aud.includes(expected); + } + return false; +} + +function identityFromPayload(payload: JWTPayload): { + sub: string; + email: string; + name: string; +} | null { + const sub = typeof payload.sub === "string" ? payload.sub : null; + const email = typeof payload.email === "string" ? payload.email : null; + const name = + (typeof payload.name === "string" && payload.name) || + (typeof payload["cognito:username"] === "string" && payload["cognito:username"]) || + email; + + if (!sub || !email || !name) { + return null; + } + return { sub, email, name }; +} + +export function createAuthMiddleware(env: ApiEnv, handle: Db) { + const jwks = + env.cognitoIssuer.length > 0 + ? createRemoteJWKSet(new URL(`${env.cognitoIssuer}/.well-known/jwks.json`)) + : null; + + return createMiddleware(async (c, next) => { + if (env.devAuthBypass) { + try { + const user = await upsertUserFromIdentity(handle, { + cognitoSub: env.devAuthSub, + email: env.devAuthEmail, + name: env.devAuthName, + role: env.devAuthRole, + }); + c.set("user", user); + } catch (error) { + if (error instanceof IdentityConflictError) { + return c.json({ error: error.message }, 409); + } + throw error; + } + await next(); + return; + } + + const header = c.req.header("authorization"); + if (!header?.startsWith("Bearer ")) { + return c.json({ error: "Missing or invalid Authorization header." }, 401); + } + + if (!jwks) { + return c.json({ error: "JWT verification is not configured." }, 401); + } + + const token = header.slice("Bearer ".length); + let payload: JWTPayload; + try { + ({ payload } = await jwtVerify(token, jwks, { + issuer: env.cognitoIssuer, + })); + } catch { + return c.json({ error: "Invalid or expired token." }, 401); + } + + if (!audienceMatches(payload, env.cognitoAudience)) { + return c.json({ error: "Token audience does not match this API." }, 401); + } + + const identity = identityFromPayload(payload); + if (!identity) { + return c.json( + { + error: + "Token is missing required identity claims. Use a Cognito ID token or an access token that includes email.", + }, + 401, + ); + } + + let user: AuthUser; + try { + user = await upsertUserFromIdentity(handle, { + cognitoSub: identity.sub, + email: identity.email, + name: identity.name, + role: roleFromClaims(payload as Record, "viewer"), + }); + } catch (error) { + if (error instanceof IdentityConflictError) { + return c.json({ error: error.message }, 409); + } + throw error; + } + + c.set("user", user); + await next(); + }); +} diff --git a/packages/api/src/auth/rbac.test.ts b/packages/api/src/auth/rbac.test.ts new file mode 100644 index 0000000..b978277 --- /dev/null +++ b/packages/api/src/auth/rbac.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { can, RBAC_ACTIONS, type RbacAction } from "./rbac.js"; +import { USER_ROLES, type UserRole } from "../env.js"; + +const EXPECTED: Record = { + admin: [...RBAC_ACTIONS], + ap_processor: ["read:me", "read:invoices", "write:invoices"], + approver: ["read:me", "read:invoices", "approve:invoices"], + viewer: ["read:me", "read:invoices"], +}; + +describe("RBAC matrix", () => { + it.each(USER_ROLES)("role %s matches the foundation matrix", (role) => { + for (const action of RBAC_ACTIONS) { + expect(can(role, action)).toBe(EXPECTED[role].includes(action)); + } + }); + + it("denies viewer write and approve actions", () => { + expect(can("viewer", "write:invoices")).toBe(false); + expect(can("viewer", "approve:invoices")).toBe(false); + expect(can("viewer", "admin:settings")).toBe(false); + }); + + it("allows admin every foundation action", () => { + for (const action of RBAC_ACTIONS) { + expect(can("admin", action)).toBe(true); + } + }); +}); diff --git a/packages/api/src/auth/rbac.ts b/packages/api/src/auth/rbac.ts new file mode 100644 index 0000000..109c73f --- /dev/null +++ b/packages/api/src/auth/rbac.ts @@ -0,0 +1,41 @@ +import type { UserRole } from "../env.js"; + +/** Foundation + forward-looking actions used by the RBAC matrix. */ +export const RBAC_ACTIONS = [ + "read:me", + "read:invoices", + "write:invoices", + "approve:invoices", + "admin:settings", +] as const; + +export type RbacAction = (typeof RBAC_ACTIONS)[number]; + +const MATRIX: Readonly>> = { + admin: new Set(RBAC_ACTIONS), + ap_processor: new Set(["read:me", "read:invoices", "write:invoices"]), + approver: new Set(["read:me", "read:invoices", "approve:invoices"]), + viewer: new Set(["read:me", "read:invoices"]), +}; + +export function can(role: UserRole, action: RbacAction): boolean { + return MATRIX[role].has(action); +} + +export function requireRole(role: UserRole, action: RbacAction): void { + if (!can(role, action)) { + throw new RbacDeniedError(role, action); + } +} + +export class RbacDeniedError extends Error { + readonly status = 403 as const; + + constructor( + readonly role: UserRole, + readonly action: RbacAction, + ) { + super(`Role ${role} is not allowed to ${action}.`); + this.name = "RbacDeniedError"; + } +} diff --git a/packages/api/src/auth/upsert-user.test.ts b/packages/api/src/auth/upsert-user.test.ts new file mode 100644 index 0000000..8eceaa8 --- /dev/null +++ b/packages/api/src/auth/upsert-user.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Db } from "../db/client.js"; +import { IdentityConflictError, upsertUserFromIdentity } from "./upsert-user.js"; + +function createDb(options: { + bySub?: Record | null; + byEmail?: Record | null; +}): Db { + const findFirst = vi.fn(async (_args: { where: unknown }) => { + // drizzle eq objects aren't introspectable here; alternate by call order. + if (findFirst.mock.calls.length === 1) { + return options.bySub ?? null; + } + return options.byEmail ?? null; + }); + + const returningRow = { + id: "11111111-1111-4111-8111-111111111111", + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + role: "admin" as const, + }; + + return { + driver: "postgres", + pool: { end: vi.fn(async () => undefined) } as never, + db: { + query: { users: { findFirst } }, + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn(async () => [returningRow]), + })), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn(async () => [returningRow]), + })), + })), + } as never, + }; +} + +describe("upsertUserFromIdentity", () => { + it("updates an existing row matched by cognito sub", async () => { + const handle = createDb({ + bySub: { + id: "11111111-1111-4111-8111-111111111111", + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Old Name", + role: "admin", + }, + }); + + const user = await upsertUserFromIdentity(handle, { + cognitoSub: "seed-sub-admin", + email: "admin@seahavenind.com", + name: "Dev Admin", + role: "admin", + }); + + expect(user.cognitoSub).toBe("seed-sub-admin"); + expect(handle.db.update).toHaveBeenCalled(); + }); + + it("refuses to rebind an email owned by a different cognito sub", async () => { + const handle = createDb({ + bySub: null, + byEmail: { + id: "11111111-1111-4111-8111-111111111111", + cognitoSub: "other-sub", + email: "admin@seahavenind.com", + name: "Seed Admin", + role: "admin", + }, + }); + + await expect( + upsertUserFromIdentity(handle, { + cognitoSub: "attacker-sub", + email: "admin@seahavenind.com", + name: "Attacker", + role: "admin", + }), + ).rejects.toBeInstanceOf(IdentityConflictError); + }); +}); diff --git a/packages/api/src/auth/upsert-user.ts b/packages/api/src/auth/upsert-user.ts new file mode 100644 index 0000000..d0373a5 --- /dev/null +++ b/packages/api/src/auth/upsert-user.ts @@ -0,0 +1,91 @@ +import { eq } from "drizzle-orm"; +import type { Db } from "../db/client.js"; +import { users } from "../db/schema/index.js"; +import type { UserRole } from "../env.js"; + +export type AuthIdentity = { + cognitoSub: string; + email: string; + name: string; + role: UserRole; +}; + +export type AuthUser = { + id: string; + cognitoSub: string; + email: string; + name: string; + role: UserRole; +}; + +export class IdentityConflictError extends Error { + readonly status = 409 as const; + + constructor(readonly email: string) { + super(`Email ${email} is already linked to a different identity.`); + this.name = "IdentityConflictError"; + } +} + +function toAuthUser(row: { + id: string; + cognitoSub: string; + email: string; + name: string; + role: UserRole; +}): AuthUser { + return { + id: row.id, + cognitoSub: row.cognitoSub, + email: row.email, + name: row.name, + role: row.role, + }; +} + +/** + * Upsert by Cognito subject only. Never rebind an existing email to a new + * subject — that would allow account takeover if email claims collide. + */ +export async function upsertUserFromIdentity( + handle: Db, + identity: AuthIdentity, +): Promise { + const bySub = await handle.db.query.users.findFirst({ + where: eq(users.cognitoSub, identity.cognitoSub), + }); + + if (bySub) { + const [updated] = await handle.db + .update(users) + .set({ + email: identity.email, + name: identity.name, + role: identity.role, + updatedAt: new Date(), + }) + .where(eq(users.id, bySub.id)) + .returning(); + return toAuthUser(updated); + } + + const byEmail = await handle.db.query.users.findFirst({ + where: eq(users.email, identity.email), + }); + + if (byEmail) { + throw new IdentityConflictError(identity.email); + } + + const [created] = await handle.db + .insert(users) + .values({ + cognitoSub: identity.cognitoSub, + email: identity.email, + name: identity.name, + role: identity.role, + }) + .returning(); + + return toAuthUser(created); +} diff --git a/packages/api/src/db/client.ts b/packages/api/src/db/client.ts new file mode 100644 index 0000000..cddde03 --- /dev/null +++ b/packages/api/src/db/client.ts @@ -0,0 +1,51 @@ +import { sql } from "drizzle-orm"; +import { RDSDataClient } from "@aws-sdk/client-rds-data"; +import { drizzle as drizzleDataApi } from "drizzle-orm/aws-data-api/pg"; +import { drizzle as drizzlePg } from "drizzle-orm/node-postgres"; +import pg from "pg"; +import type { ApiEnv } from "../env.js"; +import * as schema from "./schema/index.js"; + +export type PostgresDb = ReturnType; +export type DataApiDb = ReturnType; +export type Db = PostgresDb | DataApiDb; + +function createPostgresDb(env: ApiEnv) { + const pool = new pg.Pool({ connectionString: env.databaseUrl }); + return { + driver: "postgres" as const, + pool, + db: drizzlePg(pool, { schema }), + }; +} + +function createDataApiDb(env: ApiEnv) { + const client = new RDSDataClient({ region: env.awsRegion }); + return { + driver: "data-api" as const, + db: drizzleDataApi(client, { + database: env.rdsDatabase, + secretArn: env.rdsSecretArn, + resourceArn: env.rdsClusterArn, + schema, + }), + }; +} + +export function createDb(env: ApiEnv): Db { + if (env.databaseDriver === "data-api") { + return createDataApiDb(env); + } + return createPostgresDb(env); +} + +export async function pingDb(handle: Db): Promise { + await handle.db.execute(sql`select 1`); + return true; +} + +export async function closeDb(handle: Db): Promise { + if (handle.driver === "postgres") { + await handle.pool.end(); + } +} diff --git a/packages/api/src/db/migrate.ts b/packages/api/src/db/migrate.ts new file mode 100644 index 0000000..1506164 --- /dev/null +++ b/packages/api/src/db/migrate.ts @@ -0,0 +1,33 @@ +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { closeDb, createDb } from "./client.js"; +import { loadEnv } from "../env.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function main(): Promise { + const env = loadEnv({ + ...process.env, + DEV_AUTH_BYPASS: process.env.DEV_AUTH_BYPASS ?? "true", + }); + + if (env.databaseDriver !== "postgres") { + throw new Error("db:migrate currently supports DATABASE_DRIVER=postgres only."); + } + + const handle = createDb(env); + if (handle.driver !== "postgres") { + throw new Error("Expected postgres driver."); + } + + const migrationsFolder = path.resolve(__dirname, "../../drizzle"); + await migrate(handle.db, { migrationsFolder }); + await closeDb(handle); + console.log("Migrations applied."); +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/api/src/db/schema/enums.test.ts b/packages/api/src/db/schema/enums.test.ts new file mode 100644 index 0000000..f9b77ab --- /dev/null +++ b/packages/api/src/db/schema/enums.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { INVOICE_STATUSES, PAYMENT_STATUSES } from "@seahaven-ap/shared"; +import { invoiceStatusEnum, paymentStatusEnum, userRoleEnum } from "./index.js"; +import { USER_ROLES } from "../../env.js"; + +describe("schema enums stay aligned with shared/env", () => { + it("matches USER_ROLES", () => { + expect([...userRoleEnum.enumValues]).toEqual([...USER_ROLES]); + }); + + it("matches INVOICE_STATUSES", () => { + expect([...invoiceStatusEnum.enumValues]).toEqual([...INVOICE_STATUSES]); + }); + + it("matches PAYMENT_STATUSES", () => { + expect([...paymentStatusEnum.enumValues]).toEqual([...PAYMENT_STATUSES]); + }); +}); diff --git a/packages/api/src/db/schema/index.ts b/packages/api/src/db/schema/index.ts new file mode 100644 index 0000000..46a17da --- /dev/null +++ b/packages/api/src/db/schema/index.ts @@ -0,0 +1,198 @@ +import { + boolean, + integer, + numeric, + pgEnum, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; + +/** Keep aligned with `@seahaven-ap/shared` and `USER_ROLES` in env.ts. */ +export const userRoleEnum = pgEnum("user_role", ["admin", "ap_processor", "approver", "viewer"]); + +export const invoiceStatusEnum = pgEnum("invoice_status", [ + "pending_approval", + "approved", + "scheduled", + "paid", + "rejected", + "void", +]); + +export const paymentStatusEnum = pgEnum("payment_status", [ + "scheduled", + "payment_submitted", + "issued", + "outstanding", + "cleared", +]); + +export const paymentMethodEnum = pgEnum("payment_method", ["check", "ach"]); + +export const approvalStatusEnum = pgEnum("approval_status", [ + "pending", + "approved", + "rejected", + "skipped", +]); + +export const users = pgTable( + "users", + { + id: uuid("id").defaultRandom().primaryKey(), + cognitoSub: text("cognito_sub").notNull(), + email: text("email").notNull(), + name: text("name").notNull(), + role: userRoleEnum("role").notNull().default("viewer"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("users_cognito_sub_uidx").on(table.cognitoSub), + uniqueIndex("users_email_uidx").on(table.email), + ], +); + +export const vendors = pgTable("vendors", { + id: uuid("id").defaultRandom().primaryKey(), + name: text("name").notNull(), + email: text("email"), + defaultPaymentMethod: paymentMethodEnum("default_payment_method").notNull().default("check"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const glAccounts = pgTable("gl_accounts", { + id: uuid("id").defaultRandom().primaryKey(), + code: text("code").notNull(), + name: text("name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const departments = pgTable("departments", { + id: uuid("id").defaultRandom().primaryKey(), + code: text("code").notNull(), + name: text("name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const invoices = pgTable( + "invoices", + { + id: uuid("id").defaultRandom().primaryKey(), + vendorId: uuid("vendor_id") + .notNull() + .references(() => vendors.id), + invoiceNumber: text("invoice_number").notNull(), + amount: numeric("amount", { precision: 14, scale: 2 }).notNull(), + amountDue: numeric("amount_due", { precision: 14, scale: 2 }).notNull(), + dueDate: text("due_date").notNull(), + payDate: text("pay_date"), + sendPaymentOn: text("send_payment_on"), + status: invoiceStatusEnum("status").notNull().default("pending_approval"), + paymentMethod: paymentMethodEnum("payment_method").notNull().default("check"), + memo: text("memo").notNull().default(""), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + uniqueIndex("invoices_vendor_number_active_uidx") + .on(table.vendorId, table.invoiceNumber) + .where(sql`${table.status} <> 'void'`), + ], +); + +export const invoiceLines = pgTable("invoice_lines", { + id: uuid("id").defaultRandom().primaryKey(), + invoiceId: uuid("invoice_id") + .notNull() + .references(() => invoices.id, { onDelete: "cascade" }), + description: text("description").notNull(), + amount: numeric("amount", { precision: 14, scale: 2 }).notNull(), + glAccountId: uuid("gl_account_id").references(() => glAccounts.id), + departmentId: uuid("department_id").references(() => departments.id), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const invoiceComments = pgTable("invoice_comments", { + id: uuid("id").defaultRandom().primaryKey(), + invoiceId: uuid("invoice_id") + .notNull() + .references(() => invoices.id, { onDelete: "cascade" }), + authorUserId: uuid("author_user_id").references(() => users.id), + body: text("body").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const activityLog = pgTable("activity_log", { + id: uuid("id").defaultRandom().primaryKey(), + invoiceId: uuid("invoice_id") + .notNull() + .references(() => invoices.id, { onDelete: "cascade" }), + actorUserId: uuid("actor_user_id").references(() => users.id), + message: text("message").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const approvalPolicies = pgTable("approval_policies", { + id: uuid("id").defaultRandom().primaryKey(), + name: text("name").notNull(), + priority: integer("priority").notNull().default(100), + amountThreshold: numeric("amount_threshold", { precision: 14, scale: 2 }), + skipBelowAmount: numeric("skip_below_amount", { precision: 14, scale: 2 }), + active: boolean("active").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const approvalSteps = pgTable("approval_steps", { + id: uuid("id").defaultRandom().primaryKey(), + invoiceId: uuid("invoice_id") + .notNull() + .references(() => invoices.id, { onDelete: "cascade" }), + policyId: uuid("policy_id").references(() => approvalPolicies.id), + stepOrder: integer("step_order").notNull().default(1), + approverRole: userRoleEnum("approver_role").notNull().default("approver"), + assigneeUserId: uuid("assignee_user_id").references(() => users.id), + status: approvalStatusEnum("status").notNull().default("pending"), + actedByUserId: uuid("acted_by_user_id").references(() => users.id), + actedAt: timestamp("acted_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const documents = pgTable("documents", { + id: uuid("id").defaultRandom().primaryKey(), + invoiceId: uuid("invoice_id").references(() => invoices.id, { onDelete: "cascade" }), + objectKey: text("object_key").notNull(), + contentType: text("content_type").notNull().default("application/pdf"), + fileName: text("file_name").notNull(), + uploadedByUserId: uuid("uploaded_by_user_id").references(() => users.id), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const payRuns = pgTable("pay_runs", { + id: uuid("id").defaultRandom().primaryKey(), + createdByUserId: uuid("created_by_user_id").references(() => users.id), + status: text("status").notNull().default("draft"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const payments = pgTable("payments", { + id: uuid("id").defaultRandom().primaryKey(), + payRunId: uuid("pay_run_id").references(() => payRuns.id), + invoiceId: uuid("invoice_id").references(() => invoices.id), + vendorId: uuid("vendor_id").references(() => vendors.id), + amount: numeric("amount", { precision: 14, scale: 2 }).notNull(), + paymentMethod: paymentMethodEnum("payment_method").notNull().default("check"), + checkNumber: integer("check_number"), + status: paymentStatusEnum("status").notNull().default("scheduled"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/packages/api/src/db/seed.ts b/packages/api/src/db/seed.ts new file mode 100644 index 0000000..41d9b25 --- /dev/null +++ b/packages/api/src/db/seed.ts @@ -0,0 +1,161 @@ +import { faker } from "@faker-js/faker"; +import { closeDb, createDb } from "./client.js"; +import { loadEnv, USER_ROLES, type UserRole } from "../env.js"; +import { + activityLog, + approvalPolicies, + approvalSteps, + departments, + documents, + glAccounts, + invoiceComments, + invoiceLines, + invoices, + payRuns, + payments, + users, + vendors, +} from "./schema/index.js"; + +/** Stable UUIDs so re-seeds and docs stay deterministic. */ +const IDS = { + users: { + admin: "11111111-1111-4111-8111-111111111111", + ap_processor: "22222222-2222-4222-8222-222222222222", + approver: "33333333-3333-4333-8333-333333333333", + viewer: "44444444-4444-4444-8444-444444444444", + } satisfies Record, + vendor: "55555555-5555-4555-8555-555555555555", + gl: "66666666-6666-4666-8666-666666666666", + department: "77777777-7777-4777-8777-777777777777", + invoice: "88888888-8888-4888-8888-888888888888", + line: "99999999-9999-4999-8999-999999999999", + comment: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + activity: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + policy: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + document: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", +} as const; + +const ROLE_EMAIL: Record = { + admin: "admin@seahavenind.com", + ap_processor: "ap.processor@seahavenind.com", + approver: "approver@seahavenind.com", + viewer: "viewer@seahavenind.com", +}; + +export async function seedDatabase(): Promise { + faker.seed(14); + + const env = loadEnv({ + ...process.env, + DEV_AUTH_BYPASS: process.env.DEV_AUTH_BYPASS ?? "true", + }); + const handle = createDb(env); + const { db } = handle; + + await db.delete(documents); + await db.delete(activityLog); + await db.delete(invoiceComments); + await db.delete(invoiceLines); + await db.delete(approvalSteps); + await db.delete(payments); + await db.delete(payRuns); + await db.delete(invoices); + await db.delete(approvalPolicies); + await db.delete(departments); + await db.delete(glAccounts); + await db.delete(vendors); + await db.delete(users); + + await db.insert(users).values( + USER_ROLES.map((role) => ({ + id: IDS.users[role], + cognitoSub: `seed-sub-${role}`, + email: ROLE_EMAIL[role], + name: faker.person.fullName(), + role, + })), + ); + + await db.insert(vendors).values({ + id: IDS.vendor, + name: "Acme Facilities Supply", + email: "billing@acmefacilities.example", + defaultPaymentMethod: "check", + }); + + await db.insert(glAccounts).values({ + id: IDS.gl, + code: "6100", + name: "Facilities Expense", + }); + + await db.insert(departments).values({ + id: IDS.department, + code: "OPS", + name: "Operations", + }); + + await db.insert(approvalPolicies).values({ + id: IDS.policy, + name: "Default approver policy", + priority: 10, + amountThreshold: "0.00", + skipBelowAmount: "25.00", + active: true, + }); + + await db.insert(invoices).values({ + id: IDS.invoice, + vendorId: IDS.vendor, + invoiceNumber: "INV-1001", + amount: "1250.00", + amountDue: "1250.00", + dueDate: "2026-09-01", + payDate: null, + sendPaymentOn: null, + status: "pending_approval", + paymentMethod: "check", + memo: "Seed invoice for local smoke.", + }); + + await db.insert(invoiceLines).values({ + id: IDS.line, + invoiceId: IDS.invoice, + description: "Monthly maintenance", + amount: "1250.00", + glAccountId: IDS.gl, + departmentId: IDS.department, + }); + + await db.insert(invoiceComments).values({ + id: IDS.comment, + invoiceId: IDS.invoice, + authorUserId: IDS.users.ap_processor, + body: "Seed comment on INV-1001.", + }); + + await db.insert(activityLog).values({ + id: IDS.activity, + invoiceId: IDS.invoice, + actorUserId: IDS.users.ap_processor, + message: "Invoice created from seed.", + }); + + await db.insert(documents).values({ + id: IDS.document, + invoiceId: IDS.invoice, + objectKey: "seed/inv-1001.pdf", + contentType: "application/pdf", + fileName: "inv-1001.pdf", + uploadedByUserId: IDS.users.ap_processor, + }); + + await closeDb(handle); + console.log("Seed applied (faker seed=14)."); +} + +seedDatabase().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/api/src/env.test.ts b/packages/api/src/env.test.ts new file mode 100644 index 0000000..26d642c --- /dev/null +++ b/packages/api/src/env.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { loadEnv } from "./env.js"; + +describe("loadEnv", () => { + it("allows DEV_AUTH_BYPASS in development", () => { + const env = loadEnv({ + NODE_ENV: "development", + DEV_AUTH_BYPASS: "true", + DEV_AUTH_ROLE: "viewer", + }); + expect(env.devAuthBypass).toBe(true); + expect(env.devAuthRole).toBe("viewer"); + expect(env.port).toBe(8787); + }); + + it("allows DEV_AUTH_BYPASS in test", () => { + const env = loadEnv({ + NODE_ENV: "test", + DEV_AUTH_BYPASS: "true", + }); + expect(env.devAuthBypass).toBe(true); + }); + + it("rejects DEV_AUTH_BYPASS in production", () => { + expect(() => + loadEnv({ + NODE_ENV: "production", + DEV_AUTH_BYPASS: "true", + }), + ).toThrow(/DEV_AUTH_BYPASS/); + }); + + it("rejects DEV_AUTH_BYPASS in staging and other non-local envs", () => { + expect(() => + loadEnv({ + NODE_ENV: "staging", + DEV_AUTH_BYPASS: "true", + }), + ).toThrow(/development or test/); + + expect(() => + loadEnv({ + NODE_ENV: "preview", + DEV_AUTH_BYPASS: "true", + }), + ).toThrow(/DEV_AUTH_BYPASS/); + }); + + it("requires Cognito config when bypass is off", () => { + expect(() => + loadEnv({ + NODE_ENV: "development", + DEV_AUTH_BYPASS: "false", + }), + ).toThrow(/COGNITO_ISSUER/); + }); +}); diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts new file mode 100644 index 0000000..05d0ee2 --- /dev/null +++ b/packages/api/src/env.ts @@ -0,0 +1,81 @@ +export const USER_ROLES = ["admin", "ap_processor", "approver", "viewer"] as const; + +export type UserRole = (typeof USER_ROLES)[number]; + +export function isUserRole(value: string): value is UserRole { + return (USER_ROLES as readonly string[]).includes(value); +} + +export type ApiEnv = { + nodeEnv: string; + port: number; + databaseDriver: "postgres" | "data-api"; + databaseUrl: string; + awsRegion: string; + rdsClusterArn: string; + rdsSecretArn: string; + rdsDatabase: string; + cognitoIssuer: string; + cognitoAudience: string; + devAuthBypass: boolean; + devAuthSub: string; + devAuthEmail: string; + devAuthName: string; + devAuthRole: UserRole; +}; + +function required(name: string, value: string | undefined): string { + if (!value) { + throw new Error(`Missing required env var ${name}.`); + } + return value; +} + +export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { + const nodeEnv = env.NODE_ENV ?? "development"; + const databaseDriver = (env.DATABASE_DRIVER ?? "postgres") as ApiEnv["databaseDriver"]; + if (databaseDriver !== "postgres" && databaseDriver !== "data-api") { + throw new Error(`Invalid DATABASE_DRIVER: ${databaseDriver}`); + } + + const devAuthBypass = env.DEV_AUTH_BYPASS === "true"; + const localNodeEnvs = new Set(["development", "test"]); + if (devAuthBypass && !localNodeEnvs.has(nodeEnv)) { + throw new Error("DEV_AUTH_BYPASS is only allowed when NODE_ENV is development or test."); + } + + const rawRole = env.DEV_AUTH_ROLE ?? "admin"; + if (!isUserRole(rawRole)) { + throw new Error(`Invalid DEV_AUTH_ROLE: ${rawRole}`); + } + + const base: ApiEnv = { + nodeEnv, + port: Number(env.API_PORT ?? "8787"), + databaseDriver, + databaseUrl: env.DATABASE_URL ?? "postgresql://seahaven:seahaven@127.0.0.1:5432/seahaven_ap", + awsRegion: env.AWS_REGION ?? "us-east-1", + rdsClusterArn: env.RDS_CLUSTER_ARN ?? "", + rdsSecretArn: env.RDS_SECRET_ARN ?? "", + rdsDatabase: env.RDS_DATABASE ?? "seahaven_ap", + cognitoIssuer: env.COGNITO_ISSUER ?? "", + cognitoAudience: env.COGNITO_AUDIENCE ?? "", + devAuthBypass, + devAuthSub: env.DEV_AUTH_SUB ?? "seed-sub-admin", + devAuthEmail: env.DEV_AUTH_EMAIL ?? "admin@seahavenind.com", + devAuthName: env.DEV_AUTH_NAME ?? "Dev Admin", + devAuthRole: rawRole, + }; + + if (databaseDriver === "data-api") { + required("RDS_CLUSTER_ARN", base.rdsClusterArn); + required("RDS_SECRET_ARN", base.rdsSecretArn); + } + + if (!devAuthBypass && nodeEnv !== "test") { + required("COGNITO_ISSUER", base.cognitoIssuer); + required("COGNITO_AUDIENCE", base.cognitoAudience); + } + + return base; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 0000000..9f54b4f --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1,32 @@ +import { serve } from "@hono/node-server"; +import { createApp } from "./app.js"; +import { closeDb, createDb } from "./db/client.js"; +import { loadEnv } from "./env.js"; + +async function main(): Promise { + const env = loadEnv(); + const handle = createDb(env); + const app = createApp(env, handle); + + const server = serve({ fetch: app.fetch, port: env.port }, (info) => { + console.log(`@seahaven-ap/api listening on http://127.0.0.1:${info.port}`); + }); + + const shutdown = async () => { + server.close(); + await closeDb(handle); + process.exit(0); + }; + + process.on("SIGINT", () => { + void shutdown(); + }); + process.on("SIGTERM", () => { + void shutdown(); + }); +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/api/src/routes/health.ts b/packages/api/src/routes/health.ts new file mode 100644 index 0000000..0aec002 --- /dev/null +++ b/packages/api/src/routes/health.ts @@ -0,0 +1,18 @@ +import { Hono } from "hono"; +import type { Db } from "../db/client.js"; +import { pingDb } from "../db/client.js"; + +export function createHealthRoutes(handle: Db) { + const routes = new Hono(); + + routes.get("/health", async (c) => { + try { + await pingDb(handle); + return c.json({ status: "ok", database: "up" }); + } catch { + return c.json({ error: "Database is unavailable." }, 503); + } + }); + + return routes; +} diff --git a/packages/api/src/routes/me.ts b/packages/api/src/routes/me.ts new file mode 100644 index 0000000..e96b02f --- /dev/null +++ b/packages/api/src/routes/me.ts @@ -0,0 +1,23 @@ +import { Hono } from "hono"; +import type { AppBindings } from "../auth/middleware.js"; +import { can } from "../auth/rbac.js"; + +export function createMeRoutes() { + const routes = new Hono(); + + routes.get("/me", (c) => { + const user = c.get("user"); + if (!can(user.role, "read:me")) { + return c.json({ error: "Forbidden." }, 403); + } + + return c.json({ + id: user.id, + email: user.email, + name: user.name, + role: user.role, + }); + }); + + return routes; +} diff --git a/packages/api/tsconfig.build.json b/packages/api/tsconfig.build.json new file mode 100644 index 0000000..cf0eba4 --- /dev/null +++ b/packages/api/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 0000000..8c02f2f --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noEmitOnError": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts new file mode 100644 index 0000000..c1433e6 --- /dev/null +++ b/packages/api/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/redocly.yaml b/redocly.yaml new file mode 100644 index 0000000..4c640d9 --- /dev/null +++ b/redocly.yaml @@ -0,0 +1,130 @@ +extends: + - recommended + +apis: + seahaven-ap@v1: + root: packages/api/openapi/openapi.yaml + +rules: + # Proprietary internal license -- no SPDX identifier or public URL exists. + info-license-strict: off + rule/info-title-api: + subject: + type: Info + property: title + assertions: + pattern: /.*API.*/ + rule/info-description: + subject: + type: Info + property: description + assertions: + defined: true + operation-4xx-response: error + # Off: the live contract is {"error": string} as plain application/json + # (serialization.py error_response). Adopting RFC 7807 would be a runtime + # + SHOC-contract change, decided against 2026-07-24. + operation-4xx-problem-details-rfc7807: off + operation-operationId: error + rule/operationId-casing: + subject: + type: Operation + property: operationId + assertions: + casing: kebab-case + rule/operationId-prefix: + subject: + type: Operation + property: operationId + assertions: + pattern: /^GET|PUT|POST|DELETE|OPTIONS|HEAD|PATCH|TRACE/i + rule/operation-summary-period: + subject: + type: Operation + property: summary + assertions: + pattern: /[^.]$/ + path-not-include-query: error + # No parameter-casing rule: path parameter names (workOrderId, poNumber, + # siteCode) are camelCase by contract -- they are baked into the API Gateway + # resource paths and read by the handler's pathParameters lookup. + rule/params-must-include-examples: + severity: error + subject: + type: Parameter + assertions: + requireAny: + - example + - examples + no-http-verbs-in-paths: error + no-ambiguous-paths: error + path-segment-plural: + severity: error + exceptions: + - docs + - openapi.json + - health + - me + - api + paths-kebab-case: error + no-invalid-schema-examples: error + # No schema-properties casing rule: property names mirror the DynamoDB + # items and the shipped SHOC webhook contract -- snake_case for WO/PO + # tables, camelCase for verified-sites (legacy, issue #24). Not lintable + # to one casing without a contract break. + # Error bodies must carry the top-level "error" field (the Error schema). + # 403 is exempt: it is emitted by API Gateway's SigV4 layer with AWS's + # {"message"} shape, not by the Lambda. + response-contains-property: + severity: error + names: + "400": + - error + "401": + - error + "404": + - error + "501": + - error + request-mime-type: + severity: error + allowedValues: + - application/json + response-mime-type: + severity: error + allowedValues: + - application/json + - text/html + no-server-example.com: error + rule/no-server-localhost: + subject: + type: Server + property: url + assertions: + notPattern: /(localhost|127.0.0.1) + operation-singular-tag: error + operation-tag-defined: error + rule/tag-description: + subject: + type: Tag + property: description + assertions: + defined: true + rule/description-capitalization: + subject: + type: any + property: description + assertions: + pattern: /^([A-Z]|true|seahaven-prod)/ + rule/description-punctuation: + subject: + type: any + property: description + assertions: + pattern: /(\.|server)$/ + rule/avoid-words-in-descriptions: + subject: + type: any + property: description + assertions: + notPattern: /(simply|easy|easily|just|obviously|notethat)/i diff --git a/vite.config.ts b/vite.config.ts index a9def5c..cedafd8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,6 +12,12 @@ export default defineConfig({ }, server: { port: 3000, + proxy: { + "/api": { + target: "http://127.0.0.1:8787", + changeOrigin: true, + }, + }, }, build: { outDir: "dist",