From f39809867d7e187f8d0a0173bae25cee6cacc2c3 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:31:12 -0500 Subject: [PATCH 01/19] untrack agent files --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 9dd0e50a..726acab4 100644 --- a/.gitignore +++ b/.gitignore @@ -184,3 +184,8 @@ krow-workforce-export-latest/ # Data Connect Generated SDKs (Explicit) apps/mobile/packages/data_connect/lib/src/dataconnect_generated/ apps/web/src/dataconnect-generated/ + + +AGENTS.md +CLAUDE.md +GEMINI.md From e81eab1165b5f635cb6196bbe84b3becbf3e4487 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:41:44 -0500 Subject: [PATCH 02/19] docs: lock backend foundation plan and tracking format --- .gitignore | 1 + CHANGELOG.md | 9 + docs/MILESTONES/M4/planning/m4-api-catalog.md | 228 +++++++++++++++ ...-backend-foundation-implementation-plan.md | 269 ++++++++++++++++++ 4 files changed, 507 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 docs/MILESTONES/M4/planning/m4-api-catalog.md create mode 100644 docs/MILESTONES/M4/planning/m4-backend-foundation-implementation-plan.md diff --git a/.gitignore b/.gitignore index 726acab4..87b98195 100644 --- a/.gitignore +++ b/.gitignore @@ -189,3 +189,4 @@ apps/web/src/dataconnect-generated/ AGENTS.md CLAUDE.md GEMINI.md +TASKS.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..efc3ec16 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# KROW Workforce Change Log + +| Date | Version | Change | +|---|---|---| +| 2026-02-24 | 0.1.0 | Confirmed dev owner access and current runtime baseline in `krow-workforce-dev`. | +| 2026-02-24 | 0.1.1 | Added backend foundation implementation plan document. | +| 2026-02-24 | 0.1.2 | Added API implementation contract and transition route aliases. | +| 2026-02-24 | 0.1.3 | Added auth-first security policy with deferred role-map integration hooks. | +| 2026-02-24 | 0.1.4 | Locked defaults for idempotency, validation, bucket split, model provider, and p95 objectives. | diff --git a/docs/MILESTONES/M4/planning/m4-api-catalog.md b/docs/MILESTONES/M4/planning/m4-api-catalog.md new file mode 100644 index 00000000..c8e02353 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-api-catalog.md @@ -0,0 +1,228 @@ +# M4 API Catalog (Implementation Contract) + +Status: Draft +Date: 2026-02-24 +Owner: Technical Lead +Environment: dev + +## 1) Scope and purpose +This file defines the backend endpoint contract for the M4 foundation build. + +## 2) Global API rules +1. Canonical route groups: +- `/core/*` for foundational integration routes +- `/commands/*` for business-critical writes +2. Foundation phase security model: +- authenticated user required +- role map enforcement deferred +- policy hook required in handler design +3. Standard error envelope: +```json +{ + "code": "STRING_CODE", + "message": "Human readable message", + "details": {}, + "requestId": "optional-request-id" +} +``` +4. Required request headers: +- `Authorization: Bearer ` +- `X-Request-Id: ` (optional but recommended) +5. Required response headers: +- `X-Request-Id` +6. Validation: +- all input validated server-side +- reject unknown/invalid fields +7. Logging: +- route +- requestId +- actorId +- latencyMs +- outcome +8. Timeouts and retries: +- command writes must be retry-safe +- use idempotency keys for command write routes +9. Idempotency storage: +- store in Cloud SQL table +- key scope: `userId + route + idempotencyKey` +- key retention: 24 hours +- repeated key returns original response payload + +## 3) Compatibility aliases (transition) +1. `POST /uploadFile` -> `POST /core/upload-file` +2. `POST /createSignedUrl` -> `POST /core/create-signed-url` +3. `POST /invokeLLM` -> `POST /core/invoke-llm` + +## 4) Rate-limit baseline (initial) +1. `/core/invoke-llm`: 60 requests per minute per user +2. `/core/upload-file`: 30 requests per minute per user +3. `/core/create-signed-url`: 120 requests per minute per user +4. `/commands/*`: 60 requests per minute per user + +## 4.1 Timeout baseline (initial) +1. `/core/invoke-llm`: 20-second hard timeout +2. other `/core/*` routes: 10-second timeout +3. `/commands/*` routes: 15-second timeout + +## 5) Core routes + +## 5.1 Upload file +1. Method and route: `POST /core/upload-file` +2. Auth: required +3. Idempotency key: optional +4. Request: multipart form data +- `file` (required) +- `category` (optional) +- `visibility` (optional: `public` or `private`) +5. Success `200`: +```json +{ + "fileUri": "gs://bucket/path/file.ext", + "contentType": "application/pdf", + "size": 12345, + "bucket": "krow-uploads-private", + "path": "documents/staff/..." +} +``` +6. Errors: +- `UNAUTHENTICATED` +- `INVALID_FILE_TYPE` +- `FILE_TOO_LARGE` +- `UPLOAD_FAILED` + +## 5.2 Create signed URL +1. Method and route: `POST /core/create-signed-url` +2. Auth: required +3. Idempotency key: optional +4. Request: +```json +{ + "fileUri": "gs://bucket/path/file.ext", + "expiresInSeconds": 300 +} +``` +5. Success `200`: +```json +{ + "signedUrl": "https://...", + "expiresAt": "2026-02-24T15:00:00Z" +} +``` +6. Errors: +- `UNAUTHENTICATED` +- `FORBIDDEN_FILE_ACCESS` +- `INVALID_EXPIRES_IN` +- `SIGN_URL_FAILED` + +## 5.3 Invoke model +1. Method and route: `POST /core/invoke-llm` +2. Auth: required +3. Idempotency key: optional +4. Request: +```json +{ + "prompt": "...", + "responseJsonSchema": {}, + "fileUrls": [] +} +``` +5. Success `200`: +```json +{ + "result": {}, + "model": "provider/model-name", + "latencyMs": 980 +} +``` +6. Errors: +- `UNAUTHENTICATED` +- `INVALID_SCHEMA` +- `MODEL_TIMEOUT` +- `MODEL_FAILED` +7. Provider default: +- Vertex AI Gemini + +## 5.4 Health check +1. Method and route: `GET /healthz` +2. Auth: optional (internal policy) +3. Success `200`: +```json +{ + "ok": true, + "service": "krow-backend", + "version": "commit-or-tag" +} +``` + +## 5.5 Storage bucket policy defaults (dev) +1. Public bucket: `krow-workforce-dev-public` +2. Private bucket: `krow-workforce-dev-private` +3. Private objects are never returned directly; only signed URLs are returned. + +## 6) Command routes (wave 1) + +## 6.1 Create order +1. Method and route: `POST /commands/orders/create` +2. Auth: required +3. Idempotency key: required +4. Purpose: create order + shifts + roles atomically +5. Replaces: +- `apps/web/src/features/operations/orders/components/CreateOrderDialog.tsx` +- `apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart` +- `apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart` + +## 6.2 Update order +1. Method and route: `POST /commands/orders/{orderId}/update` +2. Auth: required +3. Idempotency key: required +4. Purpose: policy-safe multi-entity order update +5. Replaces: +- `apps/web/src/features/operations/orders/EditOrder.tsx` +- `apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart` + +## 6.3 Cancel order +1. Method and route: `POST /commands/orders/{orderId}/cancel` +2. Auth: required +3. Idempotency key: required +4. Purpose: enforce cancellation policy and return explicit conflict code +5. Replaces: +- `apps/web/src/features/operations/orders/OrderDetail.tsx` + +## 6.4 Change shift status +1. Method and route: `POST /commands/shifts/{shiftId}/change-status` +2. Auth: required +3. Idempotency key: required +4. Purpose: enforce state transitions server-side +5. Replaces: +- `apps/web/src/features/operations/tasks/TaskBoard.tsx` + +## 6.5 Assign staff +1. Method and route: `POST /commands/shifts/{shiftId}/assign-staff` +2. Auth: required +3. Idempotency key: required +4. Purpose: assign + count update + conflict checks atomically +5. Replaces: +- `apps/web/src/features/operations/orders/components/AssignStaffModal.tsx` + +## 6.6 Accept shift +1. Method and route: `POST /commands/shifts/{shiftId}/accept` +2. Auth: required +3. Idempotency key: required +4. Purpose: application + counters + rollback-safe behavior in one command +5. Replaces: +- `apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart` + +## 7) Locked defaults before coding starts +1. Idempotency keys are stored in Cloud SQL with 24-hour retention. +2. Request validation library is `zod`. +3. Validation schema location is `backend//src/contracts/`. +4. Storage buckets are: +- `krow-workforce-dev-public` +- `krow-workforce-dev-private` +5. Model provider is Vertex AI Gemini with a 20-second timeout for `/core/invoke-llm`. + +## 8) Target response-time objectives (p95) +1. `/healthz` under 200ms +2. `/core/create-signed-url` under 500ms +3. `/commands/*` under 1500ms +4. `/core/invoke-llm` under 15000ms diff --git a/docs/MILESTONES/M4/planning/m4-backend-foundation-implementation-plan.md b/docs/MILESTONES/M4/planning/m4-backend-foundation-implementation-plan.md new file mode 100644 index 00000000..31be4add --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-backend-foundation-implementation-plan.md @@ -0,0 +1,269 @@ +# M4 Backend Foundation Implementation Plan (Dev First) + +Date: 2026-02-24 +Owner: Wilfred (Technical Lead) +Primary environment: `krow-workforce-dev` + +## 1) Objective +Build a secure, modular, and scalable backend foundation in `dev` without breaking the current frontend while we migrate high-risk writes from direct Data Connect mutations to backend command endpoints. + +## 2) First-principles architecture rules +1. Client apps are untrusted for business-critical writes. +2. Backend is the enforcement layer for validation, permissions, and write orchestration. +3. Multi-entity writes must be atomic, idempotent, and observable. +4. Configuration and deployment must be reproducible by automation. +5. Migration must be backward-compatible until each frontend flow is cut over. + +## 3) Pre-coding gates (must be true before implementation starts) + +## Gate A: Security boundary +1. Frontend sends Firebase token only. No database credentials in client code. +2. Every new backend endpoint validates Firebase token. +3. Data Connect write access strategy is defined: +- keep simple reads available to client +- route high-risk writes through backend command endpoints +4. Upload and signed URL paths are server-controlled. + +## Gate B: Contract standards +1. Standard error envelope is frozen: +```json +{ + "code": "STRING_CODE", + "message": "Human readable message", + "details": {}, + "requestId": "optional-request-id" +} +``` +2. Request validation layer is chosen and centralized. +3. Route naming strategy is frozen: +- canonical routes under `/core` and `/commands` +- compatibility aliases preserved during migration (`/uploadFile`, `/createSignedUrl`, `/invokeLLM`) +4. Validation standard is locked: +- library: `zod` +- schema location: `backend//src/contracts/` with `core/` and `commands/` subfolders + +## Gate C: Atomicity and reliability +1. Command endpoints support idempotency keys for retry-safe writes. +2. Multi-step write flows are wrapped in single backend transaction boundaries. +3. Domain conflict codes are defined for expected business failures. +4. Idempotency storage is locked: +- store in Cloud SQL table +- key scope: `userId + route + idempotencyKey` +- retain records for 24 hours +- repeated key returns original response + +## Gate D: Automation and operability +1. Makefile is source of truth for backend setup and deploy in dev. +2. Core deploy and smoke test commands exist before feature migration. +3. Logging format and request tracing fields are standardized. + +## 4) Security baseline for foundation phase + +## 4.1 Authentication and authorization +1. Foundation phase is authentication-first. +2. Role-based access control is intentionally deferred. +3. All handlers include a policy hook for future role checks (`can(action, resource, actor)`). + +## 4.2 Data access control model +1. Client retains Data Connect reads required for existing screens. +2. High-risk writes move behind `/commands/*` endpoints. +3. Backend mediates write interactions with Data Connect and Cloud SQL. + +## 4.3 File and URL security +1. Validate file type and size server-side. +2. Separate public and private storage behavior. +3. Signed URL creation checks ownership/prefix scope and expiry limits. +4. Bucket policy split is locked: +- `krow-workforce-dev-public` +- `krow-workforce-dev-private` +- private bucket access only through signed URL + +## 4.4 Model invocation safety +1. Enforce schema-constrained output. +2. Apply per-user rate limits and request timeout. +3. Log model failures with safe redaction (no sensitive prompt leakage in logs). +4. Model provider and timeout defaults are locked: +- provider: Vertex AI Gemini +- max route timeout: 20 seconds +- timeout error code: `MODEL_TIMEOUT` + +## 4.5 Secrets and credentials +1. Runtime secrets come from Secret Manager only. +2. Service accounts use least-privilege roles. +3. No secrets committed in repository files. + +## 5) Modularity baseline + +## 5.1 Backend module boundaries +1. `core` module: upload, signed URL, model invocation, health. +2. `commands` module: business writes and state transitions. +3. `policy` module: validation and future role checks. +4. `data` module: Data Connect adapters and transaction wrappers. +5. `infra` module: logging, tracing, auth middleware, error mapping. + +## 5.2 Contract separation +1. Keep API request/response schemas in one location. +2. Keep domain errors in one registry file. +3. Keep route declarations thin; business logic in services. + +## 5.3 Cloud runtime roles +1. Cloud Run is the primary command and core API execution layer. +2. Cloud Functions v2 is worker-only in this phase: +- upload-related async handlers +- notification jobs +- model-related async helpers when needed + +## 6) Automation baseline + +## 6.1 Makefile requirements +Add `makefiles/backend.mk` and wire it into root `Makefile` with at least: +1. `make backend-enable-apis` +2. `make backend-bootstrap-dev` +3. `make backend-deploy-core` +4. `make backend-deploy-commands` +5. `make backend-deploy-workers` +6. `make backend-smoke-core` +7. `make backend-smoke-commands` +8. `make backend-logs-core` + +## 6.2 CI requirements +1. Backend lint +2. Backend tests +3. Build/package +4. Smoke test against deployed dev route(s) +5. Block merge on failed checks + +## 6.3 Session hygiene +1. Update `TASKS.md` and `CHANGELOG.md` each working session. +2. If a new service/API is added, Makefile target must be added in same change. + +## 7) Migration safety contract (no frontend breakage) +1. Backend routes ship first. +2. Frontend migration is per-feature wave, not big bang. +3. Keep compatibility aliases until clients migrate. +4. Keep existing Data Connect reads during foundation. +5. For each migrated write flow: +- before/after behavior checklist +- rollback path +- smoke verification + +## 8) Scope for foundation build +1. Backend runtime/deploy foundation in dev. +2. Core endpoints: +- `POST /core/upload-file` +- `POST /core/create-signed-url` +- `POST /core/invoke-llm` +- `GET /healthz` +3. Compatibility aliases: +- `POST /uploadFile` +- `POST /createSignedUrl` +- `POST /invokeLLM` +4. Command layer scaffold for first migration routes. +5. Initial migration of highest-risk write paths. + +## 9) Implementation phases + +## Phase 0: Baseline and contracts +Deliverables: +1. Freeze endpoint naming and compatibility aliases. +2. Freeze error envelope and error code registry. +3. Freeze auth middleware interface and policy hook interface. +4. Publish route inventory from web/mobile direct writes. + +Exit criteria: +1. No unresolved contract ambiguity. +2. Team agrees on auth-first now and role-map-later approach. + +## Phase 1: Backend infra and automation +Deliverables: +1. `makefiles/backend.mk` with bootstrap, deploy, smoke, logs targets. +2. Environment templates for backend runtime config. +3. Secret Manager and service account setup automation. + +Exit criteria: +1. A fresh machine can deploy core backend to dev via Make commands. + +## Phase 2: Core endpoint implementation +Deliverables: +1. `/core/upload-file` +2. `/core/create-signed-url` +3. `/core/invoke-llm` +4. `/healthz` +5. Compatibility aliases (`/uploadFile`, `/createSignedUrl`, `/invokeLLM`) + +Exit criteria: +1. API harness passes for core routes. +2. Error, logging, and auth standards are enforced. + +## Phase 3: Command layer scaffold +Deliverables: +1. `/commands/orders/create` +2. `/commands/orders/{orderId}/cancel` +3. `/commands/orders/{orderId}/update` +4. `/commands/shifts/{shiftId}/change-status` +5. `/commands/shifts/{shiftId}/assign-staff` +6. `/commands/shifts/{shiftId}/accept` + +Exit criteria: +1. High-risk writes have backend command alternatives ready. + +## Phase 4: Wave 1 frontend migration +Deliverables: +1. Replace direct writes in selected web/mobile flows. +2. Keep reads stable. +3. Verify no regressions in non-migrated screens. + +Exit criteria: +1. Migrated flows run through backend commands only. +2. Rollback instructions validated. + +## Phase 5: Hardening and handoff +Deliverables: +1. Runbook for deploy, rollback, and smoke. +2. Backend CI pipeline active. +3. Wave 2 and wave 3 migration task list defined. + +Exit criteria: +1. Foundation is reusable for staging/prod with environment changes only. + +## 10) Wave 1 migration inventory (real call sites) + +Web: +1. `apps/web/src/features/operations/tasks/TaskBoard.tsx:100` +2. `apps/web/src/features/operations/orders/OrderDetail.tsx:145` +3. `apps/web/src/features/operations/orders/EditOrder.tsx:84` +4. `apps/web/src/features/operations/orders/components/CreateOrderDialog.tsx:31` +5. `apps/web/src/features/operations/orders/components/AssignStaffModal.tsx:60` +6. `apps/web/src/features/workforce/documents/DocumentVault.tsx:99` + +Mobile: +1. `apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart:232` +2. `apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart:1195` +3. `apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart:68` +4. `apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart:446` +5. `apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart:257` +6. `apps/mobile/packages/features/staff/profile_sections/onboarding/profile_info/lib/src/data/repositories/personal_info_repository_impl.dart:51` + +## 11) Definition of done for foundation +1. Core endpoints deployed in dev and validated. +2. Command scaffolding in place for wave 1 writes. +3. Auth-first protection active on all new routes. +4. Idempotency + transaction model defined for command writes. +5. Makefile and CI automation cover bootstrap/deploy/smoke paths. +6. Frontend remains stable during migration. +7. Role-map integration points are documented for next phase. + +## 12) Locked defaults (approved) +1. Idempotency key storage strategy: +- Cloud SQL table, 24-hour retention, keyed by `userId + route + idempotencyKey`. +2. Validation library and schema location: +- `zod` in `backend//src/contracts/` (`core/`, `commands/`). +3. Storage bucket naming and split: +- `krow-workforce-dev-public` and `krow-workforce-dev-private`. +4. Model provider and timeout: +- Vertex AI Gemini, 20-second max timeout. +5. Target response-time objectives (p95): +- `/healthz` under 200ms +- `/core/create-signed-url` under 500ms +- `/commands/*` under 1500ms +- `/core/invoke-llm` under 15000ms From f8f81ec77c98943669030e2ce12cf53a3cc61b2a Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:27:40 -0500 Subject: [PATCH 03/19] feat(backend): add foundation services and sql idempotency --- .github/workflows/backend-foundation.yml | 64 + CHANGELOG.md | 2 + Makefile | 14 + backend/command-api/Dockerfile | 13 + backend/command-api/package-lock.json | 3035 +++++++++++++++++ backend/command-api/package.json | 25 + .../scripts/migrate-idempotency.mjs | 29 + .../sql/001_command_idempotency.sql | 13 + backend/command-api/src/app.js | 30 + .../src/contracts/commands/command-base.js | 6 + backend/command-api/src/lib/errors.js | 26 + backend/command-api/src/middleware/auth.js | 45 + .../src/middleware/error-handler.js | 25 + .../command-api/src/middleware/idempotency.js | 10 + .../src/middleware/request-context.js | 9 + backend/command-api/src/routes/commands.js | 113 + backend/command-api/src/routes/health.js | 12 + backend/command-api/src/server.js | 9 + .../command-api/src/services/firebase-auth.js | 13 + .../src/services/idempotency-store.js | 208 ++ backend/command-api/src/services/policy.js | 5 + backend/command-api/test/app.test.js | 54 + .../test/idempotency-store.test.js | 56 + backend/core-api/Dockerfile | 13 + backend/core-api/package-lock.json | 3004 ++++++++++++++++ backend/core-api/package.json | 24 + backend/core-api/src/app.js | 31 + .../src/contracts/core/create-signed-url.js | 6 + .../core-api/src/contracts/core/invoke-llm.js | 7 + backend/core-api/src/lib/errors.js | 26 + backend/core-api/src/middleware/auth.js | 45 + .../core-api/src/middleware/error-handler.js | 25 + .../src/middleware/request-context.js | 9 + backend/core-api/src/routes/core.js | 141 + backend/core-api/src/routes/health.js | 12 + backend/core-api/src/server.js | 9 + .../core-api/src/services/firebase-auth.js | 13 + backend/core-api/src/services/policy.js | 5 + backend/core-api/test/app.test.js | 61 + makefiles/backend.mk | 169 + 40 files changed, 7416 insertions(+) create mode 100644 .github/workflows/backend-foundation.yml create mode 100644 backend/command-api/Dockerfile create mode 100644 backend/command-api/package-lock.json create mode 100644 backend/command-api/package.json create mode 100644 backend/command-api/scripts/migrate-idempotency.mjs create mode 100644 backend/command-api/sql/001_command_idempotency.sql create mode 100644 backend/command-api/src/app.js create mode 100644 backend/command-api/src/contracts/commands/command-base.js create mode 100644 backend/command-api/src/lib/errors.js create mode 100644 backend/command-api/src/middleware/auth.js create mode 100644 backend/command-api/src/middleware/error-handler.js create mode 100644 backend/command-api/src/middleware/idempotency.js create mode 100644 backend/command-api/src/middleware/request-context.js create mode 100644 backend/command-api/src/routes/commands.js create mode 100644 backend/command-api/src/routes/health.js create mode 100644 backend/command-api/src/server.js create mode 100644 backend/command-api/src/services/firebase-auth.js create mode 100644 backend/command-api/src/services/idempotency-store.js create mode 100644 backend/command-api/src/services/policy.js create mode 100644 backend/command-api/test/app.test.js create mode 100644 backend/command-api/test/idempotency-store.test.js create mode 100644 backend/core-api/Dockerfile create mode 100644 backend/core-api/package-lock.json create mode 100644 backend/core-api/package.json create mode 100644 backend/core-api/src/app.js create mode 100644 backend/core-api/src/contracts/core/create-signed-url.js create mode 100644 backend/core-api/src/contracts/core/invoke-llm.js create mode 100644 backend/core-api/src/lib/errors.js create mode 100644 backend/core-api/src/middleware/auth.js create mode 100644 backend/core-api/src/middleware/error-handler.js create mode 100644 backend/core-api/src/middleware/request-context.js create mode 100644 backend/core-api/src/routes/core.js create mode 100644 backend/core-api/src/routes/health.js create mode 100644 backend/core-api/src/server.js create mode 100644 backend/core-api/src/services/firebase-auth.js create mode 100644 backend/core-api/src/services/policy.js create mode 100644 backend/core-api/test/app.test.js create mode 100644 makefiles/backend.mk diff --git a/.github/workflows/backend-foundation.yml b/.github/workflows/backend-foundation.yml new file mode 100644 index 00000000..0e408f8f --- /dev/null +++ b/.github/workflows/backend-foundation.yml @@ -0,0 +1,64 @@ +name: Backend Foundation + +on: + pull_request: + branches: + - dev + - main + push: + branches: + - dev + - main + +jobs: + backend-foundation-makefile: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate backend make targets + run: | + make backend-help + make help | grep "backend-" + + - name: Dry-run backend automation targets + run: | + make -n backend-enable-apis ENV=dev + make -n backend-bootstrap-dev ENV=dev + make -n backend-deploy-core ENV=dev + make -n backend-deploy-commands ENV=dev + make -n backend-deploy-workers ENV=dev + make -n backend-smoke-core ENV=dev + make -n backend-smoke-commands ENV=dev + make -n backend-logs-core ENV=dev + + backend-services-tests: + runs-on: ubuntu-latest + strategy: + matrix: + service: + - backend/core-api + - backend/command-api + defaults: + run: + working-directory: ${{ matrix.service }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: ${{ matrix.service }}/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run tests + env: + AUTH_BYPASS: "true" + LLM_MOCK: "true" + run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index efc3ec16..772f19c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,3 +7,5 @@ | 2026-02-24 | 0.1.2 | Added API implementation contract and transition route aliases. | | 2026-02-24 | 0.1.3 | Added auth-first security policy with deferred role-map integration hooks. | | 2026-02-24 | 0.1.4 | Locked defaults for idempotency, validation, bucket split, model provider, and p95 objectives. | +| 2026-02-24 | 0.1.5 | Added backend makefile module and CI workflow for backend target validation. | +| 2026-02-24 | 0.1.6 | Added Cloud SQL-backed idempotency storage, migration script, and command API test coverage. | diff --git a/Makefile b/Makefile index 2b2f8c55..8f501219 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ include makefiles/web.mk include makefiles/launchpad.mk include makefiles/mobile.mk include makefiles/dataconnect.mk +include makefiles/backend.mk include makefiles/tools.mk # --- Main Help Command --- @@ -71,6 +72,19 @@ help: @echo " make dataconnect-bootstrap-validation-database ONE-TIME: Setup validation database" @echo " make dataconnect-backup-dev-to-validation Backup dev database to validation" @echo "" + @echo " ☁️ BACKEND FOUNDATION (Cloud Run + Workers)" + @echo " ────────────────────────────────────────────────────────────────────" + @echo " make backend-help Show backend foundation commands" + @echo " make backend-enable-apis [ENV=dev] Enable backend GCP APIs" + @echo " make backend-bootstrap-dev Bootstrap backend foundation resources (dev)" + @echo " make backend-migrate-idempotency Create/upgrade command idempotency table" + @echo " make backend-deploy-core [ENV=dev] Build and deploy core API service" + @echo " make backend-deploy-commands [ENV=dev] Build and deploy command API service" + @echo " make backend-deploy-workers [ENV=dev] Deploy async worker functions scaffold" + @echo " make backend-smoke-core [ENV=dev] Run health smoke test for core service" + @echo " make backend-smoke-commands [ENV=dev] Run health smoke test for command service" + @echo " make backend-logs-core [ENV=dev] Tail/read logs for core service" + @echo "" @echo " 🛠️ DEVELOPMENT TOOLS" @echo " ────────────────────────────────────────────────────────────────────" @echo " make install-melos Install Melos globally (for mobile dev)" diff --git a/backend/command-api/Dockerfile b/backend/command-api/Dockerfile new file mode 100644 index 00000000..55a6a26b --- /dev/null +++ b/backend/command-api/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY src ./src + +ENV PORT=8080 +EXPOSE 8080 + +CMD ["node", "src/server.js"] diff --git a/backend/command-api/package-lock.json b/backend/command-api/package-lock.json new file mode 100644 index 00000000..2b8f2d6c --- /dev/null +++ b/backend/command-api/package-lock.json @@ -0,0 +1,3035 @@ +{ + "name": "@krow/command-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@krow/command-api", + "version": "0.1.0", + "dependencies": { + "express": "^4.21.2", + "firebase-admin": "^13.0.2", + "pg": "^8.16.3", + "pino": "^9.6.0", + "pino-http": "^10.3.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "supertest": "^7.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", + "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", + "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", + "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/database": "1.1.0", + "@firebase/database-types": "1.0.16", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", + "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.13.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", + "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", + "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/firebase-admin": { + "version": "13.6.1", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.6.1.tgz", + "integrity": "sha512-Zgc6yPtmPxAZo+FoK6LMG6zpSEsoSK8ifIR+IqF4oWuC3uWZU40OjxgfLTSFcsRlj/k/wD66zNv2UiTRreCNSw==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "@types/node": "^22.8.7", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^9.14.2", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.14.0" + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "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.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "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/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz", + "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "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/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.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/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "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/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, + "license": "ISC" + }, + "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/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/command-api/package.json b/backend/command-api/package.json new file mode 100644 index 00000000..c47230cf --- /dev/null +++ b/backend/command-api/package.json @@ -0,0 +1,25 @@ +{ + "name": "@krow/command-api", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/server.js", + "test": "node --test", + "migrate:idempotency": "node scripts/migrate-idempotency.mjs" + }, + "dependencies": { + "express": "^4.21.2", + "firebase-admin": "^13.0.2", + "pg": "^8.16.3", + "pino": "^9.6.0", + "pino-http": "^10.3.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "supertest": "^7.0.0" + } +} diff --git a/backend/command-api/scripts/migrate-idempotency.mjs b/backend/command-api/scripts/migrate-idempotency.mjs new file mode 100644 index 00000000..42970b84 --- /dev/null +++ b/backend/command-api/scripts/migrate-idempotency.mjs @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Pool } from 'pg'; + +const databaseUrl = process.env.IDEMPOTENCY_DATABASE_URL; + +if (!databaseUrl) { + // eslint-disable-next-line no-console + console.error('IDEMPOTENCY_DATABASE_URL is required'); + process.exit(1); +} + +const scriptDir = resolve(fileURLToPath(new URL('.', import.meta.url))); +const migrationPath = resolve(scriptDir, '../sql/001_command_idempotency.sql'); +const sql = readFileSync(migrationPath, 'utf8'); + +const pool = new Pool({ + connectionString: databaseUrl, + max: Number.parseInt(process.env.IDEMPOTENCY_DB_POOL_MAX || '5', 10), +}); + +try { + await pool.query(sql); + // eslint-disable-next-line no-console + console.log('Idempotency migration applied successfully'); +} finally { + await pool.end(); +} diff --git a/backend/command-api/sql/001_command_idempotency.sql b/backend/command-api/sql/001_command_idempotency.sql new file mode 100644 index 00000000..373ec468 --- /dev/null +++ b/backend/command-api/sql/001_command_idempotency.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS command_idempotency ( + composite_key TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + route TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_command_idempotency_expires_at + ON command_idempotency (expires_at); diff --git a/backend/command-api/src/app.js b/backend/command-api/src/app.js new file mode 100644 index 00000000..c6a72078 --- /dev/null +++ b/backend/command-api/src/app.js @@ -0,0 +1,30 @@ +import express from 'express'; +import pino from 'pino'; +import pinoHttp from 'pino-http'; +import { requestContext } from './middleware/request-context.js'; +import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; +import { healthRouter } from './routes/health.js'; +import { createCommandsRouter } from './routes/commands.js'; + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +export function createApp() { + const app = express(); + + app.use(requestContext); + app.use( + pinoHttp({ + logger, + customProps: (req) => ({ requestId: req.requestId }), + }) + ); + app.use(express.json({ limit: '2mb' })); + + app.use(healthRouter); + app.use('/commands', createCommandsRouter()); + + app.use(notFoundHandler); + app.use(errorHandler); + + return app; +} diff --git a/backend/command-api/src/contracts/commands/command-base.js b/backend/command-api/src/contracts/commands/command-base.js new file mode 100644 index 00000000..811e470e --- /dev/null +++ b/backend/command-api/src/contracts/commands/command-base.js @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const commandBaseSchema = z.object({ + payload: z.record(z.any()).optional(), + metadata: z.record(z.any()).optional(), +}); diff --git a/backend/command-api/src/lib/errors.js b/backend/command-api/src/lib/errors.js new file mode 100644 index 00000000..05548b32 --- /dev/null +++ b/backend/command-api/src/lib/errors.js @@ -0,0 +1,26 @@ +export class AppError extends Error { + constructor(code, message, status = 400, details = {}) { + super(message); + this.name = 'AppError'; + this.code = code; + this.status = status; + this.details = details; + } +} + +export function toErrorEnvelope(error, requestId) { + const status = error?.status && Number.isInteger(error.status) ? error.status : 500; + const code = error?.code || 'INTERNAL_ERROR'; + const message = error?.message || 'Unexpected error'; + const details = error?.details || {}; + + return { + status, + body: { + code, + message, + details, + requestId, + }, + }; +} diff --git a/backend/command-api/src/middleware/auth.js b/backend/command-api/src/middleware/auth.js new file mode 100644 index 00000000..9c62c86d --- /dev/null +++ b/backend/command-api/src/middleware/auth.js @@ -0,0 +1,45 @@ +import { AppError } from '../lib/errors.js'; +import { can } from '../services/policy.js'; +import { verifyFirebaseToken } from '../services/firebase-auth.js'; + +function getBearerToken(header) { + if (!header) return null; + const [scheme, token] = header.split(' '); + if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null; + return token; +} + +export async function requireAuth(req, _res, next) { + try { + const token = getBearerToken(req.get('Authorization')); + if (!token) { + throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401); + } + + if (process.env.AUTH_BYPASS === 'true') { + req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; + return next(); + } + + const decoded = await verifyFirebaseToken(token); + req.actor = { + uid: decoded.uid, + email: decoded.email || null, + role: decoded.role || null, + }; + + return next(); + } catch (error) { + if (error instanceof AppError) return next(error); + return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401)); + } +} + +export function requirePolicy(action, resource) { + return (req, _res, next) => { + if (!can(action, resource, req.actor)) { + return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + } + return next(); + }; +} diff --git a/backend/command-api/src/middleware/error-handler.js b/backend/command-api/src/middleware/error-handler.js new file mode 100644 index 00000000..289395f3 --- /dev/null +++ b/backend/command-api/src/middleware/error-handler.js @@ -0,0 +1,25 @@ +import { toErrorEnvelope } from '../lib/errors.js'; + +export function notFoundHandler(req, res) { + res.status(404).json({ + code: 'NOT_FOUND', + message: `Route not found: ${req.method} ${req.path}`, + details: {}, + requestId: req.requestId, + }); +} + +export function errorHandler(error, req, res, _next) { + const envelope = toErrorEnvelope(error, req.requestId); + if (req.log) { + req.log.error( + { + errCode: envelope.body.code, + status: envelope.status, + details: envelope.body.details, + }, + envelope.body.message + ); + } + res.status(envelope.status).json(envelope.body); +} diff --git a/backend/command-api/src/middleware/idempotency.js b/backend/command-api/src/middleware/idempotency.js new file mode 100644 index 00000000..77b41a6e --- /dev/null +++ b/backend/command-api/src/middleware/idempotency.js @@ -0,0 +1,10 @@ +import { AppError } from '../lib/errors.js'; + +export function requireIdempotencyKey(req, _res, next) { + const idempotencyKey = req.get('Idempotency-Key'); + if (!idempotencyKey) { + return next(new AppError('MISSING_IDEMPOTENCY_KEY', 'Missing Idempotency-Key header', 400)); + } + req.idempotencyKey = idempotencyKey; + return next(); +} diff --git a/backend/command-api/src/middleware/request-context.js b/backend/command-api/src/middleware/request-context.js new file mode 100644 index 00000000..c633acbb --- /dev/null +++ b/backend/command-api/src/middleware/request-context.js @@ -0,0 +1,9 @@ +import { randomUUID } from 'node:crypto'; + +export function requestContext(req, res, next) { + const incoming = req.get('X-Request-Id'); + req.requestId = incoming || randomUUID(); + res.setHeader('X-Request-Id', req.requestId); + res.locals.startedAt = Date.now(); + next(); +} diff --git a/backend/command-api/src/routes/commands.js b/backend/command-api/src/routes/commands.js new file mode 100644 index 00000000..a16d23da --- /dev/null +++ b/backend/command-api/src/routes/commands.js @@ -0,0 +1,113 @@ +import { Router } from 'express'; +import { AppError } from '../lib/errors.js'; +import { requireAuth, requirePolicy } from '../middleware/auth.js'; +import { requireIdempotencyKey } from '../middleware/idempotency.js'; +import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js'; +import { commandBaseSchema } from '../contracts/commands/command-base.js'; + +function parseBody(body) { + const parsed = commandBaseSchema.safeParse(body || {}); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Invalid command payload', 400, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + +function createCommandResponse(route, requestId, idempotencyKey) { + return { + accepted: true, + route, + commandId: `${route}:${Date.now()}`, + idempotencyKey, + requestId, + }; +} + +function buildCommandHandler(policyAction, policyResource) { + return async (req, res, next) => { + try { + parseBody(req.body); + + const route = `${req.baseUrl}${req.route.path}`; + const compositeKey = buildIdempotencyKey({ + userId: req.actor.uid, + route, + idempotencyKey: req.idempotencyKey, + }); + + const existing = await readIdempotentResult(compositeKey); + if (existing) { + return res.status(existing.statusCode).json(existing.payload); + } + + const payload = createCommandResponse(route, req.requestId, req.idempotencyKey); + const persisted = await writeIdempotentResult({ + compositeKey, + userId: req.actor.uid, + route, + idempotencyKey: req.idempotencyKey, + payload, + statusCode: 200, + }); + return res.status(persisted.statusCode).json(persisted.payload); + } catch (error) { + return next(error); + } + }; +} + +export function createCommandsRouter() { + const router = Router(); + + router.post( + '/orders/create', + requireAuth, + requireIdempotencyKey, + requirePolicy('orders.create', 'order'), + buildCommandHandler('orders.create', 'order') + ); + + router.post( + '/orders/:orderId/update', + requireAuth, + requireIdempotencyKey, + requirePolicy('orders.update', 'order'), + buildCommandHandler('orders.update', 'order') + ); + + router.post( + '/orders/:orderId/cancel', + requireAuth, + requireIdempotencyKey, + requirePolicy('orders.cancel', 'order'), + buildCommandHandler('orders.cancel', 'order') + ); + + router.post( + '/shifts/:shiftId/change-status', + requireAuth, + requireIdempotencyKey, + requirePolicy('shifts.change-status', 'shift'), + buildCommandHandler('shifts.change-status', 'shift') + ); + + router.post( + '/shifts/:shiftId/assign-staff', + requireAuth, + requireIdempotencyKey, + requirePolicy('shifts.assign-staff', 'shift'), + buildCommandHandler('shifts.assign-staff', 'shift') + ); + + router.post( + '/shifts/:shiftId/accept', + requireAuth, + requireIdempotencyKey, + requirePolicy('shifts.accept', 'shift'), + buildCommandHandler('shifts.accept', 'shift') + ); + + return router; +} diff --git a/backend/command-api/src/routes/health.js b/backend/command-api/src/routes/health.js new file mode 100644 index 00000000..6cae9737 --- /dev/null +++ b/backend/command-api/src/routes/health.js @@ -0,0 +1,12 @@ +import { Router } from 'express'; + +export const healthRouter = Router(); + +healthRouter.get('/healthz', (req, res) => { + res.status(200).json({ + ok: true, + service: 'krow-command-api', + version: process.env.SERVICE_VERSION || 'dev', + requestId: req.requestId, + }); +}); diff --git a/backend/command-api/src/server.js b/backend/command-api/src/server.js new file mode 100644 index 00000000..28720513 --- /dev/null +++ b/backend/command-api/src/server.js @@ -0,0 +1,9 @@ +import { createApp } from './app.js'; + +const port = Number(process.env.PORT || 8080); +const app = createApp(); + +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`krow-command-api listening on port ${port}`); +}); diff --git a/backend/command-api/src/services/firebase-auth.js b/backend/command-api/src/services/firebase-auth.js new file mode 100644 index 00000000..e268d5db --- /dev/null +++ b/backend/command-api/src/services/firebase-auth.js @@ -0,0 +1,13 @@ +import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app'; +import { getAuth } from 'firebase-admin/auth'; + +function ensureAdminApp() { + if (getApps().length === 0) { + initializeApp({ credential: applicationDefault() }); + } +} + +export async function verifyFirebaseToken(token) { + ensureAdminApp(); + return getAuth().verifyIdToken(token); +} diff --git a/backend/command-api/src/services/idempotency-store.js b/backend/command-api/src/services/idempotency-store.js new file mode 100644 index 00000000..8a3df3d4 --- /dev/null +++ b/backend/command-api/src/services/idempotency-store.js @@ -0,0 +1,208 @@ +import { Pool } from 'pg'; + +const DEFAULT_TTL_SECONDS = Number.parseInt(process.env.IDEMPOTENCY_TTL_SECONDS || '86400', 10); +const CLEANUP_EVERY_OPS = Number.parseInt(process.env.IDEMPOTENCY_CLEANUP_EVERY_OPS || '100', 10); + +const memoryStore = new Map(); +let adapterPromise = null; + +function shouldUseSqlStore() { + const mode = (process.env.IDEMPOTENCY_STORE || '').toLowerCase(); + if (mode === 'memory') { + return false; + } + if (mode === 'sql') { + return true; + } + return Boolean(process.env.IDEMPOTENCY_DATABASE_URL); +} + +function gcExpiredMemoryRecords(now = Date.now()) { + for (const [key, value] of memoryStore.entries()) { + if (value.expiresAt <= now) { + memoryStore.delete(key); + } + } +} + +function createMemoryAdapter() { + return { + async read(compositeKey) { + gcExpiredMemoryRecords(); + return memoryStore.get(compositeKey) || null; + }, + async write({ + compositeKey, + payload, + statusCode = 200, + }) { + const now = Date.now(); + const existing = memoryStore.get(compositeKey); + if (existing && existing.expiresAt > now) { + return existing; + } + + const record = { + payload, + statusCode, + createdAt: now, + expiresAt: now + (DEFAULT_TTL_SECONDS * 1000), + }; + memoryStore.set(compositeKey, record); + return record; + }, + }; +} + +async function createSqlAdapter() { + const connectionString = process.env.IDEMPOTENCY_DATABASE_URL; + if (!connectionString) { + throw new Error('IDEMPOTENCY_DATABASE_URL is required for sql idempotency store'); + } + + const pool = new Pool({ + connectionString, + max: Number.parseInt(process.env.IDEMPOTENCY_DB_POOL_MAX || '5', 10), + }); + + await pool.query(` + CREATE TABLE IF NOT EXISTS command_idempotency ( + composite_key TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + route TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL + ); + `); + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_command_idempotency_expires_at + ON command_idempotency (expires_at); + `); + + let opCount = 0; + + async function maybeCleanupExpiredRows() { + opCount += 1; + if (CLEANUP_EVERY_OPS <= 0 || opCount % CLEANUP_EVERY_OPS !== 0) { + return; + } + await pool.query('DELETE FROM command_idempotency WHERE expires_at <= NOW()'); + } + + function mapRow(row) { + return { + statusCode: row.status_code, + payload: row.response_payload, + }; + } + + return { + async read(compositeKey) { + await maybeCleanupExpiredRows(); + const result = await pool.query( + ` + SELECT status_code, response_payload + FROM command_idempotency + WHERE composite_key = $1 + AND expires_at > NOW() + `, + [compositeKey] + ); + + if (result.rowCount === 0) { + return null; + } + return mapRow(result.rows[0]); + }, + async write({ + compositeKey, + userId, + route, + idempotencyKey, + payload, + statusCode = 200, + }) { + await maybeCleanupExpiredRows(); + + const expiresAt = new Date(Date.now() + (DEFAULT_TTL_SECONDS * 1000)); + const payloadJson = JSON.stringify(payload); + + await pool.query( + ` + INSERT INTO command_idempotency ( + composite_key, + user_id, + route, + idempotency_key, + status_code, + response_payload, + expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7) + ON CONFLICT (composite_key) DO NOTHING + `, + [compositeKey, userId, route, idempotencyKey, statusCode, payloadJson, expiresAt] + ); + + const existingResult = await pool.query( + ` + SELECT status_code, response_payload + FROM command_idempotency + WHERE composite_key = $1 + AND expires_at > NOW() + `, + [compositeKey] + ); + + if (existingResult.rowCount === 0) { + throw new Error('Idempotency write failed to persist or recover existing record'); + } + return mapRow(existingResult.rows[0]); + }, + }; +} + +async function getAdapter() { + if (!adapterPromise) { + adapterPromise = shouldUseSqlStore() + ? createSqlAdapter() + : Promise.resolve(createMemoryAdapter()); + } + return adapterPromise; +} + +export function buildIdempotencyKey({ userId, route, idempotencyKey }) { + return `${userId}:${route}:${idempotencyKey}`; +} + +export async function readIdempotentResult(compositeKey) { + const adapter = await getAdapter(); + return adapter.read(compositeKey); +} + +export async function writeIdempotentResult({ + compositeKey, + userId, + route, + idempotencyKey, + payload, + statusCode = 200, +}) { + const adapter = await getAdapter(); + return adapter.write({ + compositeKey, + userId, + route, + idempotencyKey, + payload, + statusCode, + }); +} + +export function __resetIdempotencyStoreForTests() { + memoryStore.clear(); + adapterPromise = null; +} diff --git a/backend/command-api/src/services/policy.js b/backend/command-api/src/services/policy.js new file mode 100644 index 00000000..44e7e371 --- /dev/null +++ b/backend/command-api/src/services/policy.js @@ -0,0 +1,5 @@ +export function can(action, resource, actor) { + void action; + void resource; + return Boolean(actor?.uid); +} diff --git a/backend/command-api/test/app.test.js b/backend/command-api/test/app.test.js new file mode 100644 index 00000000..bce88d82 --- /dev/null +++ b/backend/command-api/test/app.test.js @@ -0,0 +1,54 @@ +import test, { beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createApp } from '../src/app.js'; +import { __resetIdempotencyStoreForTests } from '../src/services/idempotency-store.js'; + +process.env.AUTH_BYPASS = 'true'; + +beforeEach(() => { + process.env.IDEMPOTENCY_STORE = 'memory'; + delete process.env.IDEMPOTENCY_DATABASE_URL; + __resetIdempotencyStoreForTests(); +}); + +test('GET /healthz returns healthy response', async () => { + const app = createApp(); + const res = await request(app).get('/healthz'); + + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(typeof res.body.requestId, 'string'); +}); + +test('command route requires idempotency key', async () => { + const app = createApp(); + const res = await request(app) + .post('/commands/orders/create') + .set('Authorization', 'Bearer test-token') + .send({ payload: {} }); + + assert.equal(res.status, 400); + assert.equal(res.body.code, 'MISSING_IDEMPOTENCY_KEY'); +}); + +test('command route is idempotent by key', async () => { + const app = createApp(); + + const first = await request(app) + .post('/commands/orders/create') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'abc-123') + .send({ payload: { order: 'x' } }); + + const second = await request(app) + .post('/commands/orders/create') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'abc-123') + .send({ payload: { order: 'x' } }); + + assert.equal(first.status, 200); + assert.equal(second.status, 200); + assert.equal(first.body.commandId, second.body.commandId); + assert.equal(first.body.idempotencyKey, 'abc-123'); +}); diff --git a/backend/command-api/test/idempotency-store.test.js b/backend/command-api/test/idempotency-store.test.js new file mode 100644 index 00000000..cd70d03d --- /dev/null +++ b/backend/command-api/test/idempotency-store.test.js @@ -0,0 +1,56 @@ +import test, { beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + __resetIdempotencyStoreForTests, + buildIdempotencyKey, + readIdempotentResult, + writeIdempotentResult, +} from '../src/services/idempotency-store.js'; + +beforeEach(() => { + process.env.IDEMPOTENCY_STORE = 'memory'; + delete process.env.IDEMPOTENCY_DATABASE_URL; + __resetIdempotencyStoreForTests(); +}); + +test('buildIdempotencyKey composes user route and client key', () => { + const key = buildIdempotencyKey({ + userId: 'user-1', + route: '/commands/orders/create', + idempotencyKey: 'req-abc', + }); + + assert.equal(key, 'user-1:/commands/orders/create:req-abc'); +}); + +test('memory idempotency store returns existing payload for duplicate key', async () => { + const compositeKey = buildIdempotencyKey({ + userId: 'user-1', + route: '/commands/orders/create', + idempotencyKey: 'req-abc', + }); + + const first = await writeIdempotentResult({ + compositeKey, + userId: 'user-1', + route: '/commands/orders/create', + idempotencyKey: 'req-abc', + payload: { accepted: true, commandId: 'c-1' }, + statusCode: 200, + }); + + const second = await writeIdempotentResult({ + compositeKey, + userId: 'user-1', + route: '/commands/orders/create', + idempotencyKey: 'req-abc', + payload: { accepted: true, commandId: 'c-2' }, + statusCode: 200, + }); + + const read = await readIdempotentResult(compositeKey); + + assert.equal(first.payload.commandId, 'c-1'); + assert.equal(second.payload.commandId, 'c-1'); + assert.equal(read.payload.commandId, 'c-1'); +}); diff --git a/backend/core-api/Dockerfile b/backend/core-api/Dockerfile new file mode 100644 index 00000000..55a6a26b --- /dev/null +++ b/backend/core-api/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY src ./src + +ENV PORT=8080 +EXPOSE 8080 + +CMD ["node", "src/server.js"] diff --git a/backend/core-api/package-lock.json b/backend/core-api/package-lock.json new file mode 100644 index 00000000..ba4fc6a6 --- /dev/null +++ b/backend/core-api/package-lock.json @@ -0,0 +1,3004 @@ +{ + "name": "@krow/core-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@krow/core-api", + "version": "0.1.0", + "dependencies": { + "express": "^4.21.2", + "firebase-admin": "^13.0.2", + "multer": "^2.0.2", + "pino": "^9.6.0", + "pino-http": "^10.3.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "supertest": "^7.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", + "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", + "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", + "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/database": "1.1.0", + "@firebase/database-types": "1.0.16", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", + "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.13.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", + "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "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==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz", + "integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/firebase-admin": { + "version": "13.6.1", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.6.1.tgz", + "integrity": "sha512-Zgc6yPtmPxAZo+FoK6LMG6zpSEsoSK8ifIR+IqF4oWuC3uWZU40OjxgfLTSFcsRlj/k/wD66zNv2UiTRreCNSw==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "@types/node": "^22.8.7", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^9.14.2", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.14.0" + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz", + "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.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/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "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/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, + "license": "ISC" + }, + "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/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/core-api/package.json b/backend/core-api/package.json new file mode 100644 index 00000000..b287621a --- /dev/null +++ b/backend/core-api/package.json @@ -0,0 +1,24 @@ +{ + "name": "@krow/core-api", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/server.js", + "test": "node --test" + }, + "dependencies": { + "express": "^4.21.2", + "firebase-admin": "^13.0.2", + "multer": "^2.0.2", + "pino": "^9.6.0", + "pino-http": "^10.3.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "supertest": "^7.0.0" + } +} diff --git a/backend/core-api/src/app.js b/backend/core-api/src/app.js new file mode 100644 index 00000000..af2f1a13 --- /dev/null +++ b/backend/core-api/src/app.js @@ -0,0 +1,31 @@ +import express from 'express'; +import pino from 'pino'; +import pinoHttp from 'pino-http'; +import { requestContext } from './middleware/request-context.js'; +import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; +import { healthRouter } from './routes/health.js'; +import { createCoreRouter, createLegacyCoreRouter } from './routes/core.js'; + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +export function createApp() { + const app = express(); + + app.use(requestContext); + app.use( + pinoHttp({ + logger, + customProps: (req) => ({ requestId: req.requestId }), + }) + ); + app.use(express.json({ limit: '2mb' })); + + app.use(healthRouter); + app.use('/core', createCoreRouter()); + app.use('/', createLegacyCoreRouter()); + + app.use(notFoundHandler); + app.use(errorHandler); + + return app; +} diff --git a/backend/core-api/src/contracts/core/create-signed-url.js b/backend/core-api/src/contracts/core/create-signed-url.js new file mode 100644 index 00000000..726893cc --- /dev/null +++ b/backend/core-api/src/contracts/core/create-signed-url.js @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const createSignedUrlSchema = z.object({ + fileUri: z.string().startsWith('gs://', 'fileUri must start with gs://'), + expiresInSeconds: z.number().int().min(60).max(3600).optional(), +}); diff --git a/backend/core-api/src/contracts/core/invoke-llm.js b/backend/core-api/src/contracts/core/invoke-llm.js new file mode 100644 index 00000000..d8dcb3cb --- /dev/null +++ b/backend/core-api/src/contracts/core/invoke-llm.js @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const invokeLlmSchema = z.object({ + prompt: z.string().min(1).max(12000), + responseJsonSchema: z.record(z.any()), + fileUrls: z.array(z.string().url()).optional(), +}); diff --git a/backend/core-api/src/lib/errors.js b/backend/core-api/src/lib/errors.js new file mode 100644 index 00000000..05548b32 --- /dev/null +++ b/backend/core-api/src/lib/errors.js @@ -0,0 +1,26 @@ +export class AppError extends Error { + constructor(code, message, status = 400, details = {}) { + super(message); + this.name = 'AppError'; + this.code = code; + this.status = status; + this.details = details; + } +} + +export function toErrorEnvelope(error, requestId) { + const status = error?.status && Number.isInteger(error.status) ? error.status : 500; + const code = error?.code || 'INTERNAL_ERROR'; + const message = error?.message || 'Unexpected error'; + const details = error?.details || {}; + + return { + status, + body: { + code, + message, + details, + requestId, + }, + }; +} diff --git a/backend/core-api/src/middleware/auth.js b/backend/core-api/src/middleware/auth.js new file mode 100644 index 00000000..9c62c86d --- /dev/null +++ b/backend/core-api/src/middleware/auth.js @@ -0,0 +1,45 @@ +import { AppError } from '../lib/errors.js'; +import { can } from '../services/policy.js'; +import { verifyFirebaseToken } from '../services/firebase-auth.js'; + +function getBearerToken(header) { + if (!header) return null; + const [scheme, token] = header.split(' '); + if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null; + return token; +} + +export async function requireAuth(req, _res, next) { + try { + const token = getBearerToken(req.get('Authorization')); + if (!token) { + throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401); + } + + if (process.env.AUTH_BYPASS === 'true') { + req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; + return next(); + } + + const decoded = await verifyFirebaseToken(token); + req.actor = { + uid: decoded.uid, + email: decoded.email || null, + role: decoded.role || null, + }; + + return next(); + } catch (error) { + if (error instanceof AppError) return next(error); + return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401)); + } +} + +export function requirePolicy(action, resource) { + return (req, _res, next) => { + if (!can(action, resource, req.actor)) { + return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + } + return next(); + }; +} diff --git a/backend/core-api/src/middleware/error-handler.js b/backend/core-api/src/middleware/error-handler.js new file mode 100644 index 00000000..289395f3 --- /dev/null +++ b/backend/core-api/src/middleware/error-handler.js @@ -0,0 +1,25 @@ +import { toErrorEnvelope } from '../lib/errors.js'; + +export function notFoundHandler(req, res) { + res.status(404).json({ + code: 'NOT_FOUND', + message: `Route not found: ${req.method} ${req.path}`, + details: {}, + requestId: req.requestId, + }); +} + +export function errorHandler(error, req, res, _next) { + const envelope = toErrorEnvelope(error, req.requestId); + if (req.log) { + req.log.error( + { + errCode: envelope.body.code, + status: envelope.status, + details: envelope.body.details, + }, + envelope.body.message + ); + } + res.status(envelope.status).json(envelope.body); +} diff --git a/backend/core-api/src/middleware/request-context.js b/backend/core-api/src/middleware/request-context.js new file mode 100644 index 00000000..c633acbb --- /dev/null +++ b/backend/core-api/src/middleware/request-context.js @@ -0,0 +1,9 @@ +import { randomUUID } from 'node:crypto'; + +export function requestContext(req, res, next) { + const incoming = req.get('X-Request-Id'); + req.requestId = incoming || randomUUID(); + res.setHeader('X-Request-Id', req.requestId); + res.locals.startedAt = Date.now(); + next(); +} diff --git a/backend/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js new file mode 100644 index 00000000..72e1b93c --- /dev/null +++ b/backend/core-api/src/routes/core.js @@ -0,0 +1,141 @@ +import { Router } from 'express'; +import multer from 'multer'; +import { z } from 'zod'; +import { AppError } from '../lib/errors.js'; +import { requireAuth, requirePolicy } from '../middleware/auth.js'; +import { createSignedUrlSchema } from '../contracts/core/create-signed-url.js'; +import { invokeLlmSchema } from '../contracts/core/invoke-llm.js'; + +const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; +const ALLOWED_FILE_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']); + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: Number(process.env.MAX_UPLOAD_BYTES || DEFAULT_MAX_FILE_BYTES), + }, +}); + +const uploadMetaSchema = z.object({ + category: z.string().max(80).optional(), + visibility: z.enum(['public', 'private']).optional(), +}); + +function mockSignedUrl(fileUri, expiresInSeconds) { + const encoded = encodeURIComponent(fileUri); + const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString(); + return { + signedUrl: `https://storage.googleapis.com/mock-signed-url/${encoded}?expires=${expiresInSeconds}`, + expiresAt, + }; +} + +function parseBody(schema, body) { + const parsed = schema.safeParse(body); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Invalid request payload', 400, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + +async function handleUploadFile(req, res, next) { + try { + const file = req.file; + if (!file) { + throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); + } + + if (!ALLOWED_FILE_TYPES.has(file.mimetype)) { + throw new AppError('INVALID_FILE_TYPE', `Unsupported file type: ${file.mimetype}`, 400); + } + + const maxFileSize = Number(process.env.MAX_UPLOAD_BYTES || DEFAULT_MAX_FILE_BYTES); + if (file.size > maxFileSize) { + throw new AppError('FILE_TOO_LARGE', `File exceeds ${maxFileSize} bytes`, 400); + } + + const meta = parseBody(uploadMetaSchema, req.body || {}); + const visibility = meta.visibility || 'private'; + const bucket = visibility === 'public' + ? process.env.PUBLIC_BUCKET || 'krow-workforce-dev-public' + : process.env.PRIVATE_BUCKET || 'krow-workforce-dev-private'; + + const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); + const objectPath = `uploads/${req.actor.uid}/${Date.now()}_${safeName}`; + + res.status(200).json({ + fileUri: `gs://${bucket}/${objectPath}`, + contentType: file.mimetype, + size: file.size, + bucket, + path: objectPath, + requestId: req.requestId, + }); + } catch (error) { + if (error?.code === 'LIMIT_FILE_SIZE') { + return next(new AppError('FILE_TOO_LARGE', 'File exceeds upload limit', 400)); + } + return next(error); + } +} + +async function handleCreateSignedUrl(req, res, next) { + try { + const payload = parseBody(createSignedUrlSchema, req.body || {}); + const expiresInSeconds = payload.expiresInSeconds || 300; + + const signed = mockSignedUrl(payload.fileUri, expiresInSeconds); + + res.status(200).json({ + ...signed, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleInvokeLlm(req, res, next) { + try { + const payload = parseBody(invokeLlmSchema, req.body || {}); + + if (process.env.LLM_MOCK === 'false') { + throw new AppError('MODEL_FAILED', 'Real model integration not wired yet', 501); + } + + const startedAt = Date.now(); + res.status(200).json({ + result: { + summary: 'Mock model response. Replace with Vertex AI integration.', + inputPromptSize: payload.prompt.length, + }, + model: process.env.LLM_MODEL || 'vertexai/gemini-mock', + latencyMs: Date.now() - startedAt, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +export function createCoreRouter() { + const router = Router(); + + router.post('/upload-file', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleUploadFile); + router.post('/create-signed-url', requireAuth, requirePolicy('core.sign-url', 'file'), handleCreateSignedUrl); + router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm); + + return router; +} + +export function createLegacyCoreRouter() { + const router = Router(); + + router.post('/uploadFile', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleUploadFile); + router.post('/createSignedUrl', requireAuth, requirePolicy('core.sign-url', 'file'), handleCreateSignedUrl); + router.post('/invokeLLM', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm); + + return router; +} diff --git a/backend/core-api/src/routes/health.js b/backend/core-api/src/routes/health.js new file mode 100644 index 00000000..761a044a --- /dev/null +++ b/backend/core-api/src/routes/health.js @@ -0,0 +1,12 @@ +import { Router } from 'express'; + +export const healthRouter = Router(); + +healthRouter.get('/healthz', (req, res) => { + res.status(200).json({ + ok: true, + service: 'krow-core-api', + version: process.env.SERVICE_VERSION || 'dev', + requestId: req.requestId, + }); +}); diff --git a/backend/core-api/src/server.js b/backend/core-api/src/server.js new file mode 100644 index 00000000..6b618206 --- /dev/null +++ b/backend/core-api/src/server.js @@ -0,0 +1,9 @@ +import { createApp } from './app.js'; + +const port = Number(process.env.PORT || 8080); +const app = createApp(); + +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`krow-core-api listening on port ${port}`); +}); diff --git a/backend/core-api/src/services/firebase-auth.js b/backend/core-api/src/services/firebase-auth.js new file mode 100644 index 00000000..e268d5db --- /dev/null +++ b/backend/core-api/src/services/firebase-auth.js @@ -0,0 +1,13 @@ +import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app'; +import { getAuth } from 'firebase-admin/auth'; + +function ensureAdminApp() { + if (getApps().length === 0) { + initializeApp({ credential: applicationDefault() }); + } +} + +export async function verifyFirebaseToken(token) { + ensureAdminApp(); + return getAuth().verifyIdToken(token); +} diff --git a/backend/core-api/src/services/policy.js b/backend/core-api/src/services/policy.js new file mode 100644 index 00000000..44e7e371 --- /dev/null +++ b/backend/core-api/src/services/policy.js @@ -0,0 +1,5 @@ +export function can(action, resource, actor) { + void action; + void resource; + return Boolean(actor?.uid); +} diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js new file mode 100644 index 00000000..19845d95 --- /dev/null +++ b/backend/core-api/test/app.test.js @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createApp } from '../src/app.js'; + +process.env.AUTH_BYPASS = 'true'; +process.env.LLM_MOCK = 'true'; + +test('GET /healthz returns healthy response', async () => { + const app = createApp(); + const res = await request(app).get('/healthz'); + + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(typeof res.body.requestId, 'string'); + assert.equal(typeof res.headers['x-request-id'], 'string'); +}); + +test('POST /core/create-signed-url requires auth', async () => { + process.env.AUTH_BYPASS = 'false'; + const app = createApp(); + const res = await request(app).post('/core/create-signed-url').send({ + fileUri: 'gs://krow-workforce-dev-private/foo.pdf', + }); + + assert.equal(res.status, 401); + assert.equal(res.body.code, 'UNAUTHENTICATED'); + process.env.AUTH_BYPASS = 'true'; +}); + +test('POST /core/create-signed-url returns signed URL', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/create-signed-url') + .set('Authorization', 'Bearer test-token') + .send({ + fileUri: 'gs://krow-workforce-dev-private/foo.pdf', + expiresInSeconds: 300, + }); + + assert.equal(res.status, 200); + assert.equal(typeof res.body.signedUrl, 'string'); + assert.equal(typeof res.body.expiresAt, 'string'); + assert.equal(typeof res.body.requestId, 'string'); +}); + +test('POST /invokeLLM legacy alias works', async () => { + const app = createApp(); + const res = await request(app) + .post('/invokeLLM') + .set('Authorization', 'Bearer test-token') + .send({ + prompt: 'hello', + responseJsonSchema: { type: 'object' }, + fileUrls: [], + }); + + assert.equal(res.status, 200); + assert.equal(typeof res.body.result, 'object'); + assert.equal(typeof res.body.model, 'string'); +}); diff --git a/makefiles/backend.mk b/makefiles/backend.mk new file mode 100644 index 00000000..1e0e4a36 --- /dev/null +++ b/makefiles/backend.mk @@ -0,0 +1,169 @@ +# --- Backend Foundation (Cloud Run + Workers) --- + +BACKEND_REGION ?= us-central1 +BACKEND_ARTIFACT_REPO ?= krow-backend + +BACKEND_CORE_SERVICE_NAME ?= krow-core-api +BACKEND_COMMAND_SERVICE_NAME ?= krow-command-api +BACKEND_RUNTIME_SA_NAME ?= krow-backend-runtime +BACKEND_RUNTIME_SA_EMAIL := $(BACKEND_RUNTIME_SA_NAME)@$(GCP_PROJECT_ID).iam.gserviceaccount.com + +BACKEND_CORE_DIR ?= backend/core-api +BACKEND_COMMAND_DIR ?= backend/command-api +BACKEND_WORKERS_DIR ?= backend/cloud-functions + +BACKEND_DEV_PUBLIC_BUCKET ?= krow-workforce-dev-public +BACKEND_DEV_PRIVATE_BUCKET ?= krow-workforce-dev-private +BACKEND_STAGING_PUBLIC_BUCKET ?= krow-workforce-staging-public +BACKEND_STAGING_PRIVATE_BUCKET ?= krow-workforce-staging-private + +ifeq ($(ENV),staging) + BACKEND_PUBLIC_BUCKET := $(BACKEND_STAGING_PUBLIC_BUCKET) + BACKEND_PRIVATE_BUCKET := $(BACKEND_STAGING_PRIVATE_BUCKET) +else + BACKEND_PUBLIC_BUCKET := $(BACKEND_DEV_PUBLIC_BUCKET) + BACKEND_PRIVATE_BUCKET := $(BACKEND_DEV_PRIVATE_BUCKET) +endif + +BACKEND_CORE_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/core-api:latest +BACKEND_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/command-api:latest +BACKEND_LOG_LIMIT ?= 100 + +.PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core + +backend-help: + @echo "--> Backend Foundation Commands" + @echo " make backend-enable-apis [ENV=dev] Enable Cloud Run/Functions/Build/Secret APIs" + @echo " make backend-bootstrap-dev Bootstrap artifact repo, runtime SA, and buckets" + @echo " make backend-migrate-idempotency Create/upgrade idempotency table in Cloud SQL" + @echo " make backend-deploy-core [ENV=dev] Build + deploy core API service" + @echo " make backend-deploy-commands [ENV=dev] Build + deploy command API service" + @echo " make backend-deploy-workers [ENV=dev] Deploy worker scaffold" + @echo " make backend-smoke-core [ENV=dev] Smoke test core /healthz" + @echo " make backend-smoke-commands [ENV=dev] Smoke test commands /healthz" + @echo " make backend-logs-core [ENV=dev] Read core service logs" + +backend-enable-apis: + @echo "--> Enabling backend APIs on project [$(GCP_PROJECT_ID)]..." + @for api in \ + run.googleapis.com \ + cloudbuild.googleapis.com \ + artifactregistry.googleapis.com \ + secretmanager.googleapis.com \ + cloudfunctions.googleapis.com \ + eventarc.googleapis.com \ + storage.googleapis.com \ + iam.googleapis.com \ + serviceusage.googleapis.com \ + firebase.googleapis.com; do \ + echo " - $$api"; \ + gcloud services enable $$api --project=$(GCP_PROJECT_ID); \ + done + @echo "✅ Backend APIs enabled." + +backend-bootstrap-dev: backend-enable-apis + @echo "--> Bootstrapping backend foundation for [$(ENV)] on project [$(GCP_PROJECT_ID)]..." + @echo "--> Ensuring Artifact Registry repo [$(BACKEND_ARTIFACT_REPO)] exists..." + @if ! gcloud artifacts repositories describe $(BACKEND_ARTIFACT_REPO) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud artifacts repositories create $(BACKEND_ARTIFACT_REPO) \ + --repository-format=docker \ + --location=$(BACKEND_REGION) \ + --description="KROW backend services" \ + --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Artifact Registry repo already exists."; \ + fi + @echo "--> Ensuring runtime service account [$(BACKEND_RUNTIME_SA_NAME)] exists..." + @if ! gcloud iam service-accounts describe $(BACKEND_RUNTIME_SA_EMAIL) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud iam service-accounts create $(BACKEND_RUNTIME_SA_NAME) \ + --display-name="KROW Backend Runtime" \ + --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Runtime service account already exists."; \ + fi + @echo "--> Ensuring storage buckets exist..." + @if ! gcloud storage buckets describe gs://$(BACKEND_PUBLIC_BUCKET) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud storage buckets create gs://$(BACKEND_PUBLIC_BUCKET) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Public bucket already exists: $(BACKEND_PUBLIC_BUCKET)"; \ + fi + @if ! gcloud storage buckets describe gs://$(BACKEND_PRIVATE_BUCKET) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud storage buckets create gs://$(BACKEND_PRIVATE_BUCKET) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Private bucket already exists: $(BACKEND_PRIVATE_BUCKET)"; \ + fi + @echo "✅ Backend foundation bootstrap complete for [$(ENV)]." + +backend-migrate-idempotency: + @echo "--> Applying idempotency table migration..." + @test -n "$(IDEMPOTENCY_DATABASE_URL)" || (echo "❌ IDEMPOTENCY_DATABASE_URL is required" && exit 1) + @cd $(BACKEND_COMMAND_DIR) && IDEMPOTENCY_DATABASE_URL="$(IDEMPOTENCY_DATABASE_URL)" npm run migrate:idempotency + @echo "✅ Idempotency migration applied." + +backend-deploy-core: + @echo "--> Deploying core backend service [$(BACKEND_CORE_SERVICE_NAME)] to [$(ENV)]..." + @test -d $(BACKEND_CORE_DIR) || (echo "❌ Missing directory: $(BACKEND_CORE_DIR)" && exit 1) + @test -f $(BACKEND_CORE_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_CORE_DIR)/Dockerfile" && exit 1) + @gcloud builds submit $(BACKEND_CORE_DIR) --tag $(BACKEND_CORE_IMAGE) --project=$(GCP_PROJECT_ID) + @gcloud run deploy $(BACKEND_CORE_SERVICE_NAME) \ + --image=$(BACKEND_CORE_IMAGE) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --service-account=$(BACKEND_RUNTIME_SA_EMAIL) \ + --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET) \ + --no-allow-unauthenticated + @echo "✅ Core backend service deployed." + +backend-deploy-commands: + @echo "--> Deploying command backend service [$(BACKEND_COMMAND_SERVICE_NAME)] to [$(ENV)]..." + @test -d $(BACKEND_COMMAND_DIR) || (echo "❌ Missing directory: $(BACKEND_COMMAND_DIR)" && exit 1) + @test -f $(BACKEND_COMMAND_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_COMMAND_DIR)/Dockerfile" && exit 1) + @gcloud builds submit $(BACKEND_COMMAND_DIR) --tag $(BACKEND_COMMAND_IMAGE) --project=$(GCP_PROJECT_ID) + @gcloud run deploy $(BACKEND_COMMAND_SERVICE_NAME) \ + --image=$(BACKEND_COMMAND_IMAGE) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --service-account=$(BACKEND_RUNTIME_SA_EMAIL) \ + --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET) \ + --no-allow-unauthenticated + @echo "✅ Command backend service deployed." + +backend-deploy-workers: + @echo "--> Deploying worker scaffold for [$(ENV)]..." + @if [ ! -d "$(BACKEND_WORKERS_DIR)" ]; then \ + echo "❌ Missing directory: $(BACKEND_WORKERS_DIR)"; \ + exit 1; \ + fi + @if [ -z "$$(find $(BACKEND_WORKERS_DIR) -mindepth 1 ! -name '.keep' -print -quit)" ]; then \ + echo "⚠️ No worker code found in $(BACKEND_WORKERS_DIR). Skipping deployment."; \ + exit 0; \ + fi + @echo "⚠️ Worker deployment is scaffold-only for now." + @echo " Add concrete worker deployment commands once worker code is introduced." + +backend-smoke-core: + @echo "--> Running core smoke check..." + @URL=$$(gcloud run services describe $(BACKEND_CORE_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + if [ -z "$$URL" ]; then \ + echo "❌ Could not resolve URL for service $(BACKEND_CORE_SERVICE_NAME)"; \ + exit 1; \ + fi; \ + TOKEN=$$(gcloud auth print-identity-token); \ + curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/healthz" >/dev/null && echo "✅ Core smoke check passed: $$URL/healthz" + +backend-smoke-commands: + @echo "--> Running commands smoke check..." + @URL=$$(gcloud run services describe $(BACKEND_COMMAND_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + if [ -z "$$URL" ]; then \ + echo "❌ Could not resolve URL for service $(BACKEND_COMMAND_SERVICE_NAME)"; \ + exit 1; \ + fi; \ + TOKEN=$$(gcloud auth print-identity-token); \ + curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/healthz" >/dev/null && echo "✅ Commands smoke check passed: $$URL/healthz" + +backend-logs-core: + @echo "--> Reading logs for core backend service [$(BACKEND_CORE_SERVICE_NAME)]..." + @gcloud run services logs read $(BACKEND_CORE_SERVICE_NAME) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --limit=$(BACKEND_LOG_LIMIT) From 1876441a12e45446b4121214eaa54de4468582e5 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 24 Feb 2026 08:58:49 -0500 Subject: [PATCH 04/19] fix(backend): use /health for cloud run smoke endpoints --- CHANGELOG.md | 1 + Makefile | 4 ++-- backend/command-api/src/routes/health.js | 7 +++++-- backend/core-api/src/routes/health.js | 7 +++++-- makefiles/backend.mk | 8 ++++---- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 772f19c8..a8a953d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,3 +9,4 @@ | 2026-02-24 | 0.1.4 | Locked defaults for idempotency, validation, bucket split, model provider, and p95 objectives. | | 2026-02-24 | 0.1.5 | Added backend makefile module and CI workflow for backend target validation. | | 2026-02-24 | 0.1.6 | Added Cloud SQL-backed idempotency storage, migration script, and command API test coverage. | +| 2026-02-24 | 0.1.7 | Added `/health` endpoints and switched smoke checks to `/health` for Cloud Run compatibility. | diff --git a/Makefile b/Makefile index 8f501219..7872163b 100644 --- a/Makefile +++ b/Makefile @@ -81,8 +81,8 @@ help: @echo " make backend-deploy-core [ENV=dev] Build and deploy core API service" @echo " make backend-deploy-commands [ENV=dev] Build and deploy command API service" @echo " make backend-deploy-workers [ENV=dev] Deploy async worker functions scaffold" - @echo " make backend-smoke-core [ENV=dev] Run health smoke test for core service" - @echo " make backend-smoke-commands [ENV=dev] Run health smoke test for command service" + @echo " make backend-smoke-core [ENV=dev] Run health smoke test for core service (/health)" + @echo " make backend-smoke-commands [ENV=dev] Run health smoke test for command service (/health)" @echo " make backend-logs-core [ENV=dev] Tail/read logs for core service" @echo "" @echo " 🛠️ DEVELOPMENT TOOLS" diff --git a/backend/command-api/src/routes/health.js b/backend/command-api/src/routes/health.js index 6cae9737..90cd5690 100644 --- a/backend/command-api/src/routes/health.js +++ b/backend/command-api/src/routes/health.js @@ -2,11 +2,14 @@ import { Router } from 'express'; export const healthRouter = Router(); -healthRouter.get('/healthz', (req, res) => { +function healthHandler(req, res) { res.status(200).json({ ok: true, service: 'krow-command-api', version: process.env.SERVICE_VERSION || 'dev', requestId: req.requestId, }); -}); +} + +healthRouter.get('/health', healthHandler); +healthRouter.get('/healthz', healthHandler); diff --git a/backend/core-api/src/routes/health.js b/backend/core-api/src/routes/health.js index 761a044a..9196cc83 100644 --- a/backend/core-api/src/routes/health.js +++ b/backend/core-api/src/routes/health.js @@ -2,11 +2,14 @@ import { Router } from 'express'; export const healthRouter = Router(); -healthRouter.get('/healthz', (req, res) => { +function healthHandler(req, res) { res.status(200).json({ ok: true, service: 'krow-core-api', version: process.env.SERVICE_VERSION || 'dev', requestId: req.requestId, }); -}); +} + +healthRouter.get('/health', healthHandler); +healthRouter.get('/healthz', healthHandler); diff --git a/makefiles/backend.mk b/makefiles/backend.mk index 1e0e4a36..356bbee3 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -39,8 +39,8 @@ backend-help: @echo " make backend-deploy-core [ENV=dev] Build + deploy core API service" @echo " make backend-deploy-commands [ENV=dev] Build + deploy command API service" @echo " make backend-deploy-workers [ENV=dev] Deploy worker scaffold" - @echo " make backend-smoke-core [ENV=dev] Smoke test core /healthz" - @echo " make backend-smoke-commands [ENV=dev] Smoke test commands /healthz" + @echo " make backend-smoke-core [ENV=dev] Smoke test core /health" + @echo " make backend-smoke-commands [ENV=dev] Smoke test commands /health" @echo " make backend-logs-core [ENV=dev] Read core service logs" backend-enable-apis: @@ -149,7 +149,7 @@ backend-smoke-core: exit 1; \ fi; \ TOKEN=$$(gcloud auth print-identity-token); \ - curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/healthz" >/dev/null && echo "✅ Core smoke check passed: $$URL/healthz" + curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/health" >/dev/null && echo "✅ Core smoke check passed: $$URL/health" backend-smoke-commands: @echo "--> Running commands smoke check..." @@ -159,7 +159,7 @@ backend-smoke-commands: exit 1; \ fi; \ TOKEN=$$(gcloud auth print-identity-token); \ - curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/healthz" >/dev/null && echo "✅ Commands smoke check passed: $$URL/healthz" + curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/health" >/dev/null && echo "✅ Commands smoke check passed: $$URL/health" backend-logs-core: @echo "--> Reading logs for core backend service [$(BACKEND_CORE_SERVICE_NAME)]..." From d3aec0da0bb893784f0628c6b76ab1c2b8482457 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:09:08 -0500 Subject: [PATCH 05/19] chore(backend): make dev deploy frontend-callable --- CHANGELOG.md | 1 + makefiles/backend.mk | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8a953d7..82bec66f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,3 +10,4 @@ | 2026-02-24 | 0.1.5 | Added backend makefile module and CI workflow for backend target validation. | | 2026-02-24 | 0.1.6 | Added Cloud SQL-backed idempotency storage, migration script, and command API test coverage. | | 2026-02-24 | 0.1.7 | Added `/health` endpoints and switched smoke checks to `/health` for Cloud Run compatibility. | +| 2026-02-24 | 0.1.8 | Enabled dev frontend reachability and made deploy auth mode environment-aware (`dev` public, `staging` private). | diff --git a/makefiles/backend.mk b/makefiles/backend.mk index 356bbee3..6706b224 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -20,9 +20,11 @@ BACKEND_STAGING_PRIVATE_BUCKET ?= krow-workforce-staging-private ifeq ($(ENV),staging) BACKEND_PUBLIC_BUCKET := $(BACKEND_STAGING_PUBLIC_BUCKET) BACKEND_PRIVATE_BUCKET := $(BACKEND_STAGING_PRIVATE_BUCKET) + BACKEND_RUN_AUTH_FLAG := --no-allow-unauthenticated else BACKEND_PUBLIC_BUCKET := $(BACKEND_DEV_PUBLIC_BUCKET) BACKEND_PRIVATE_BUCKET := $(BACKEND_DEV_PRIVATE_BUCKET) + BACKEND_RUN_AUTH_FLAG := --allow-unauthenticated endif BACKEND_CORE_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/core-api:latest @@ -111,7 +113,7 @@ backend-deploy-core: --project=$(GCP_PROJECT_ID) \ --service-account=$(BACKEND_RUNTIME_SA_EMAIL) \ --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET) \ - --no-allow-unauthenticated + $(BACKEND_RUN_AUTH_FLAG) @echo "✅ Core backend service deployed." backend-deploy-commands: @@ -125,7 +127,7 @@ backend-deploy-commands: --project=$(GCP_PROJECT_ID) \ --service-account=$(BACKEND_RUNTIME_SA_EMAIL) \ --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET) \ - --no-allow-unauthenticated + $(BACKEND_RUN_AUTH_FLAG) @echo "✅ Command backend service deployed." backend-deploy-workers: From e733f36d286b28bc56d0e7d0407e83b11be7f080 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:58:22 -0500 Subject: [PATCH 06/19] feat(core-api): wire real gcs upload and vertex llm in dev --- CHANGELOG.md | 1 + backend/core-api/package-lock.json | 62 +++------------- backend/core-api/package.json | 2 + backend/core-api/src/routes/core.js | 46 ++++++++++-- backend/core-api/src/services/llm.js | 93 ++++++++++++++++++++++++ backend/core-api/src/services/storage.js | 59 +++++++++++++++ makefiles/backend.mk | 19 ++++- 7 files changed, 223 insertions(+), 59 deletions(-) create mode 100644 backend/core-api/src/services/llm.js create mode 100644 backend/core-api/src/services/storage.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 82bec66f..05e74d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,3 +11,4 @@ | 2026-02-24 | 0.1.6 | Added Cloud SQL-backed idempotency storage, migration script, and command API test coverage. | | 2026-02-24 | 0.1.7 | Added `/health` endpoints and switched smoke checks to `/health` for Cloud Run compatibility. | | 2026-02-24 | 0.1.8 | Enabled dev frontend reachability and made deploy auth mode environment-aware (`dev` public, `staging` private). | +| 2026-02-24 | 0.1.9 | Switched core API from mock behavior to real GCS upload/signed URLs and real Vertex model calls in dev deployment. | diff --git a/backend/core-api/package-lock.json b/backend/core-api/package-lock.json index ba4fc6a6..87370c92 100644 --- a/backend/core-api/package-lock.json +++ b/backend/core-api/package-lock.json @@ -8,8 +8,10 @@ "name": "@krow/core-api", "version": "0.1.0", "dependencies": { + "@google-cloud/storage": "^7.16.0", "express": "^4.21.2", "firebase-admin": "^13.0.2", + "google-auth-library": "^9.15.1", "multer": "^2.0.2", "pino": "^9.6.0", "pino-http": "^10.3.0", @@ -151,7 +153,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", "license": "Apache-2.0", - "optional": true, "dependencies": { "arrify": "^2.0.0", "extend": "^3.0.2" @@ -165,7 +166,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=14.0.0" } @@ -175,7 +175,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=14" } @@ -185,7 +184,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@google-cloud/paginator": "^5.0.0", "@google-cloud/projectify": "^4.0.0", @@ -212,7 +210,6 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -398,7 +395,6 @@ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "license": "MIT", - "optional": true, "engines": { "node": ">= 10" } @@ -407,8 +403,7 @@ "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", @@ -447,7 +442,6 @@ "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", "license": "MIT", - "optional": true, "dependencies": { "@types/caseless": "*", "@types/node": "*", @@ -459,15 +453,13 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", - "optional": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -540,7 +532,6 @@ "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -557,7 +548,6 @@ "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", "license": "MIT", - "optional": true, "dependencies": { "retry": "0.13.1" } @@ -566,7 +556,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "devOptional": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -731,7 +720,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "devOptional": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -821,7 +809,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -876,7 +863,6 @@ "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", "license": "MIT", - "optional": true, "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", @@ -920,7 +906,6 @@ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -959,7 +944,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -1001,7 +985,6 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=6" } @@ -1091,7 +1074,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "strnum": "^2.1.2" }, @@ -1160,7 +1142,6 @@ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", "license": "MIT", - "optional": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -1419,7 +1400,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "devOptional": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -1457,8 +1437,7 @@ "url": "https://patreon.com/mdevils" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/http-errors": { "version": "2.0.1", @@ -1491,7 +1470,6 @@ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", "license": "MIT", - "optional": true, "dependencies": { "@tootallnate/once": "2", "agent-base": "6", @@ -1506,7 +1484,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -1519,7 +1496,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -1536,8 +1512,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/https-proxy-agent": { "version": "7.0.6", @@ -1860,7 +1835,6 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", "license": "MIT", - "optional": true, "bin": { "mime": "cli.js" }, @@ -2028,7 +2002,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -2039,7 +2012,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "license": "MIT", - "optional": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -2264,7 +2236,6 @@ "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 4" } @@ -2274,7 +2245,6 @@ "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", "license": "MIT", - "optional": true, "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", @@ -2498,7 +2468,6 @@ "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", "license": "MIT", - "optional": true, "dependencies": { "stubs": "^3.0.0" } @@ -2507,8 +2476,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/streamsearch": { "version": "1.1.0", @@ -2565,15 +2533,13 @@ "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/superagent": { "version": "10.3.0", @@ -2681,7 +2647,6 @@ "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", "license": "Apache-2.0", - "optional": true, "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", @@ -2698,7 +2663,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -2711,7 +2675,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -2729,7 +2692,6 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", - "optional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -2742,8 +2704,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/teeny-request/node_modules/uuid": { "version": "9.0.1", @@ -2754,7 +2715,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -2921,7 +2881,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "devOptional": true, "license": "ISC" }, "node_modules/xtend": { @@ -2983,7 +2942,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "license": "MIT", - "optional": true, "engines": { "node": ">=10" }, diff --git a/backend/core-api/package.json b/backend/core-api/package.json index b287621a..0e9b2f6d 100644 --- a/backend/core-api/package.json +++ b/backend/core-api/package.json @@ -11,8 +11,10 @@ "test": "node --test" }, "dependencies": { + "@google-cloud/storage": "^7.16.0", "express": "^4.21.2", "firebase-admin": "^13.0.2", + "google-auth-library": "^9.15.1", "multer": "^2.0.2", "pino": "^9.6.0", "pino-http": "^10.3.0", diff --git a/backend/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js index 72e1b93c..a753ae22 100644 --- a/backend/core-api/src/routes/core.js +++ b/backend/core-api/src/routes/core.js @@ -5,6 +5,8 @@ import { AppError } from '../lib/errors.js'; import { requireAuth, requirePolicy } from '../middleware/auth.js'; import { createSignedUrlSchema } from '../contracts/core/create-signed-url.js'; import { invokeLlmSchema } from '../contracts/core/invoke-llm.js'; +import { invokeVertexModel } from '../services/llm.js'; +import { generateReadSignedUrl, uploadToGcs } from '../services/storage.js'; const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; const ALLOWED_FILE_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']); @@ -30,6 +32,14 @@ function mockSignedUrl(fileUri, expiresInSeconds) { }; } +function useMockSignedUrl() { + return process.env.SIGNED_URL_MOCK !== 'false'; +} + +function useMockUpload() { + return process.env.UPLOAD_MOCK !== 'false'; +} + function parseBody(schema, body) { const parsed = schema.safeParse(body); if (!parsed.success) { @@ -64,9 +74,19 @@ async function handleUploadFile(req, res, next) { const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_'); const objectPath = `uploads/${req.actor.uid}/${Date.now()}_${safeName}`; + const fileUri = `gs://${bucket}/${objectPath}`; + + if (!useMockUpload()) { + await uploadToGcs({ + bucket, + objectPath, + contentType: file.mimetype, + buffer: file.buffer, + }); + } res.status(200).json({ - fileUri: `gs://${bucket}/${objectPath}`, + fileUri, contentType: file.mimetype, size: file.size, bucket, @@ -85,8 +105,12 @@ async function handleCreateSignedUrl(req, res, next) { try { const payload = parseBody(createSignedUrlSchema, req.body || {}); const expiresInSeconds = payload.expiresInSeconds || 300; - - const signed = mockSignedUrl(payload.fileUri, expiresInSeconds); + const signed = useMockSignedUrl() + ? mockSignedUrl(payload.fileUri, expiresInSeconds) + : await generateReadSignedUrl({ + fileUri: payload.fileUri, + expiresInSeconds, + }); res.status(200).json({ ...signed, @@ -101,12 +125,22 @@ async function handleInvokeLlm(req, res, next) { try { const payload = parseBody(invokeLlmSchema, req.body || {}); + const startedAt = Date.now(); if (process.env.LLM_MOCK === 'false') { - throw new AppError('MODEL_FAILED', 'Real model integration not wired yet', 501); + const llmResult = await invokeVertexModel({ + prompt: payload.prompt, + responseJsonSchema: payload.responseJsonSchema, + fileUrls: payload.fileUrls, + }); + return res.status(200).json({ + result: llmResult.result, + model: llmResult.model, + latencyMs: Date.now() - startedAt, + requestId: req.requestId, + }); } - const startedAt = Date.now(); - res.status(200).json({ + return res.status(200).json({ result: { summary: 'Mock model response. Replace with Vertex AI integration.', inputPromptSize: payload.prompt.length, diff --git a/backend/core-api/src/services/llm.js b/backend/core-api/src/services/llm.js new file mode 100644 index 00000000..31d8b17e --- /dev/null +++ b/backend/core-api/src/services/llm.js @@ -0,0 +1,93 @@ +import { GoogleAuth } from 'google-auth-library'; +import { AppError } from '../lib/errors.js'; + +function buildVertexConfig() { + const project = process.env.GCP_PROJECT_ID || process.env.GOOGLE_CLOUD_PROJECT; + const location = process.env.LLM_LOCATION || process.env.BACKEND_REGION || 'us-central1'; + if (!project) { + throw new AppError('MODEL_FAILED', 'GCP project is required for model invocation', 500); + } + + return { + project, + location, + }; +} + +function withTimeout(promise, timeoutMs) { + return Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => { + reject(new AppError('MODEL_TIMEOUT', `Model request exceeded ${timeoutMs}ms`, 504)); + }, timeoutMs); + }), + ]); +} + +function toTextFromCandidate(candidate) { + if (!candidate?.content?.parts) { + return ''; + } + return candidate.content.parts + .map((part) => part?.text || '') + .join('') + .trim(); +} + +export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = [] }) { + const { project, location } = buildVertexConfig(); + const model = process.env.LLM_MODEL || 'gemini-2.0-flash-001'; + const timeoutMs = Number.parseInt(process.env.LLM_TIMEOUT_MS || '20000', 10); + const schemaText = JSON.stringify(responseJsonSchema); + const fileContext = fileUrls.length > 0 ? `\nFiles:\n${fileUrls.join('\n')}` : ''; + const instruction = `Respond with strict JSON only. Follow this schema exactly:\n${schemaText}`; + const textPrompt = `${prompt}\n\n${instruction}${fileContext}`; + const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/publishers/google/models/${model}:generateContent`; + const auth = new GoogleAuth({ + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + + let response; + try { + const client = await auth.getClient(); + response = await withTimeout( + client.request({ + url, + method: 'POST', + data: { + contents: [{ role: 'user', parts: [{ text: textPrompt }] }], + generationConfig: { + temperature: 0.2, + responseMimeType: 'application/json', + }, + }, + }), + timeoutMs + ); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + throw new AppError('MODEL_FAILED', 'Model invocation failed', 502); + } + + const text = toTextFromCandidate(response?.data?.candidates?.[0]); + if (!text) { + throw new AppError('MODEL_FAILED', 'Model returned empty response', 502); + } + + try { + return { + model, + result: JSON.parse(text), + }; + } catch { + return { + model, + result: { + raw: text, + }, + }; + } +} diff --git a/backend/core-api/src/services/storage.js b/backend/core-api/src/services/storage.js new file mode 100644 index 00000000..4e4b2f5c --- /dev/null +++ b/backend/core-api/src/services/storage.js @@ -0,0 +1,59 @@ +import { Storage } from '@google-cloud/storage'; +import { AppError } from '../lib/errors.js'; + +const storage = new Storage(); + +function parseGsUri(fileUri) { + if (!fileUri.startsWith('gs://')) { + throw new AppError('VALIDATION_ERROR', 'fileUri must start with gs://', 400); + } + + const raw = fileUri.replace('gs://', ''); + const slashIndex = raw.indexOf('/'); + if (slashIndex <= 0 || slashIndex >= raw.length - 1) { + throw new AppError('VALIDATION_ERROR', 'fileUri must include bucket and object path', 400); + } + + return { + bucket: raw.slice(0, slashIndex), + path: raw.slice(slashIndex + 1), + }; +} + +function allowedBuckets() { + return new Set([ + process.env.PUBLIC_BUCKET || 'krow-workforce-dev-public', + process.env.PRIVATE_BUCKET || 'krow-workforce-dev-private', + ]); +} + +export async function uploadToGcs({ bucket, objectPath, contentType, buffer }) { + const file = storage.bucket(bucket).file(objectPath); + await file.save(buffer, { + resumable: false, + contentType, + metadata: { + cacheControl: 'private, max-age=0', + }, + }); +} + +export async function generateReadSignedUrl({ fileUri, expiresInSeconds }) { + const { bucket, path } = parseGsUri(fileUri); + if (!allowedBuckets().has(bucket)) { + throw new AppError('FORBIDDEN', `Bucket not allowed for signing: ${bucket}`, 403); + } + + const file = storage.bucket(bucket).file(path); + const expiresAtMs = Date.now() + expiresInSeconds * 1000; + const [signedUrl] = await file.getSignedUrl({ + version: 'v4', + action: 'read', + expires: expiresAtMs, + }); + + return { + signedUrl, + expiresAt: new Date(expiresAtMs).toISOString(), + }; +} diff --git a/makefiles/backend.mk b/makefiles/backend.mk index 6706b224..77b9d6f7 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -30,6 +30,7 @@ endif BACKEND_CORE_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/core-api:latest BACKEND_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/command-api:latest BACKEND_LOG_LIMIT ?= 100 +BACKEND_LLM_MODEL ?= gemini-2.0-flash-001 .PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core @@ -54,8 +55,10 @@ backend-enable-apis: secretmanager.googleapis.com \ cloudfunctions.googleapis.com \ eventarc.googleapis.com \ + aiplatform.googleapis.com \ storage.googleapis.com \ iam.googleapis.com \ + iamcredentials.googleapis.com \ serviceusage.googleapis.com \ firebase.googleapis.com; do \ echo " - $$api"; \ @@ -83,6 +86,20 @@ backend-bootstrap-dev: backend-enable-apis else \ echo " - Runtime service account already exists."; \ fi + @echo "--> Ensuring runtime service account IAM roles..." + @gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \ + --member="serviceAccount:$(BACKEND_RUNTIME_SA_EMAIL)" \ + --role="roles/storage.objectAdmin" \ + --quiet >/dev/null + @gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \ + --member="serviceAccount:$(BACKEND_RUNTIME_SA_EMAIL)" \ + --role="roles/aiplatform.user" \ + --quiet >/dev/null + @gcloud iam service-accounts add-iam-policy-binding $(BACKEND_RUNTIME_SA_EMAIL) \ + --member="serviceAccount:$(BACKEND_RUNTIME_SA_EMAIL)" \ + --role="roles/iam.serviceAccountTokenCreator" \ + --project=$(GCP_PROJECT_ID) \ + --quiet >/dev/null @echo "--> Ensuring storage buckets exist..." @if ! gcloud storage buckets describe gs://$(BACKEND_PUBLIC_BUCKET) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ gcloud storage buckets create gs://$(BACKEND_PUBLIC_BUCKET) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID); \ @@ -112,7 +129,7 @@ backend-deploy-core: --region=$(BACKEND_REGION) \ --project=$(GCP_PROJECT_ID) \ --service-account=$(BACKEND_RUNTIME_SA_EMAIL) \ - --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET) \ + --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000 \ $(BACKEND_RUN_AUTH_FLAG) @echo "✅ Core backend service deployed." From 52c3fbad40616b9d5b0e8b0d3d89c54f9a328f25 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:17:48 -0500 Subject: [PATCH 07/19] feat(core-api): harden signed urls and llm rate limits --- CHANGELOG.md | 1 + .../core-api/src/middleware/error-handler.js | 3 + backend/core-api/src/routes/core.js | 27 ++++++- .../core-api/src/services/llm-rate-limit.js | 41 +++++++++++ backend/core-api/src/services/storage.js | 27 +++++-- backend/core-api/test/app.test.js | 72 +++++++++++++++++-- makefiles/backend.mk | 4 +- 7 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 backend/core-api/src/services/llm-rate-limit.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e74d1a..a4c5a013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,3 +12,4 @@ | 2026-02-24 | 0.1.7 | Added `/health` endpoints and switched smoke checks to `/health` for Cloud Run compatibility. | | 2026-02-24 | 0.1.8 | Enabled dev frontend reachability and made deploy auth mode environment-aware (`dev` public, `staging` private). | | 2026-02-24 | 0.1.9 | Switched core API from mock behavior to real GCS upload/signed URLs and real Vertex model calls in dev deployment. | +| 2026-02-24 | 0.1.10 | Hardened core APIs with signed URL ownership/expiry checks, object existence checks, and per-user LLM rate limiting. | diff --git a/backend/core-api/src/middleware/error-handler.js b/backend/core-api/src/middleware/error-handler.js index 289395f3..2a08b112 100644 --- a/backend/core-api/src/middleware/error-handler.js +++ b/backend/core-api/src/middleware/error-handler.js @@ -11,6 +11,9 @@ export function notFoundHandler(req, res) { export function errorHandler(error, req, res, _next) { const envelope = toErrorEnvelope(error, req.requestId); + if (envelope.status === 429 && envelope.body.details?.retryAfterSeconds) { + res.set('Retry-After', String(envelope.body.details.retryAfterSeconds)); + } if (req.log) { req.log.error( { diff --git a/backend/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js index a753ae22..d73b0170 100644 --- a/backend/core-api/src/routes/core.js +++ b/backend/core-api/src/routes/core.js @@ -6,9 +6,11 @@ import { requireAuth, requirePolicy } from '../middleware/auth.js'; import { createSignedUrlSchema } from '../contracts/core/create-signed-url.js'; import { invokeLlmSchema } from '../contracts/core/invoke-llm.js'; import { invokeVertexModel } from '../services/llm.js'; -import { generateReadSignedUrl, uploadToGcs } from '../services/storage.js'; +import { checkLlmRateLimit } from '../services/llm-rate-limit.js'; +import { generateReadSignedUrl, uploadToGcs, validateFileUriAccess } from '../services/storage.js'; const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; +const DEFAULT_MAX_SIGNED_URL_SECONDS = 900; const ALLOWED_FILE_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']); const upload = multer({ @@ -105,10 +107,25 @@ async function handleCreateSignedUrl(req, res, next) { try { const payload = parseBody(createSignedUrlSchema, req.body || {}); const expiresInSeconds = payload.expiresInSeconds || 300; + const maxSignedUrlSeconds = Number.parseInt( + process.env.MAX_SIGNED_URL_SECONDS || `${DEFAULT_MAX_SIGNED_URL_SECONDS}`, + 10 + ); + if (expiresInSeconds > maxSignedUrlSeconds) { + throw new AppError('VALIDATION_ERROR', `expiresInSeconds must be <= ${maxSignedUrlSeconds}`, 400); + } + const signed = useMockSignedUrl() - ? mockSignedUrl(payload.fileUri, expiresInSeconds) + ? (() => { + validateFileUriAccess({ + fileUri: payload.fileUri, + actorUid: req.actor.uid, + }); + return mockSignedUrl(payload.fileUri, expiresInSeconds); + })() : await generateReadSignedUrl({ fileUri: payload.fileUri, + actorUid: req.actor.uid, expiresInSeconds, }); @@ -124,6 +141,12 @@ async function handleCreateSignedUrl(req, res, next) { async function handleInvokeLlm(req, res, next) { try { const payload = parseBody(invokeLlmSchema, req.body || {}); + const rateLimit = checkLlmRateLimit({ uid: req.actor.uid }); + if (!rateLimit.allowed) { + throw new AppError('RATE_LIMITED', 'Too many model requests. Please retry shortly.', 429, { + retryAfterSeconds: rateLimit.retryAfterSeconds, + }); + } const startedAt = Date.now(); if (process.env.LLM_MOCK === 'false') { diff --git a/backend/core-api/src/services/llm-rate-limit.js b/backend/core-api/src/services/llm-rate-limit.js new file mode 100644 index 00000000..2e63f330 --- /dev/null +++ b/backend/core-api/src/services/llm-rate-limit.js @@ -0,0 +1,41 @@ +const counters = new Map(); + +function currentWindowKey(now = Date.now()) { + return Math.floor(now / 60000); +} + +function perMinuteLimit() { + return Number.parseInt(process.env.LLM_RATE_LIMIT_PER_MINUTE || '20', 10); +} + +export function checkLlmRateLimit({ uid, now = Date.now() }) { + const limit = perMinuteLimit(); + if (!Number.isFinite(limit) || limit <= 0) { + return { allowed: true, remaining: null, retryAfterSeconds: 0 }; + } + + const windowKey = currentWindowKey(now); + const record = counters.get(uid); + + if (!record || record.windowKey !== windowKey) { + counters.set(uid, { windowKey, count: 1 }); + return { allowed: true, remaining: limit - 1, retryAfterSeconds: 0 }; + } + + if (record.count >= limit) { + const retryAfterSeconds = (windowKey + 1) * 60 - Math.floor(now / 1000); + return { + allowed: false, + remaining: 0, + retryAfterSeconds: Math.max(1, retryAfterSeconds), + }; + } + + record.count += 1; + counters.set(uid, record); + return { allowed: true, remaining: limit - record.count, retryAfterSeconds: 0 }; +} + +export function __resetLlmRateLimitForTests() { + counters.clear(); +} diff --git a/backend/core-api/src/services/storage.js b/backend/core-api/src/services/storage.js index 4e4b2f5c..da0dd382 100644 --- a/backend/core-api/src/services/storage.js +++ b/backend/core-api/src/services/storage.js @@ -3,7 +3,7 @@ import { AppError } from '../lib/errors.js'; const storage = new Storage(); -function parseGsUri(fileUri) { +export function parseGsUri(fileUri) { if (!fileUri.startsWith('gs://')) { throw new AppError('VALIDATION_ERROR', 'fileUri must start with gs://', 400); } @@ -27,6 +27,20 @@ function allowedBuckets() { ]); } +export function validateFileUriAccess({ fileUri, actorUid }) { + const { bucket, path } = parseGsUri(fileUri); + if (!allowedBuckets().has(bucket)) { + throw new AppError('FORBIDDEN', `Bucket not allowed for signing: ${bucket}`, 403); + } + + const ownedPrefix = `uploads/${actorUid}/`; + if (!path.startsWith(ownedPrefix)) { + throw new AppError('FORBIDDEN', 'Cannot sign URL for another user path', 403); + } + + return { bucket, path }; +} + export async function uploadToGcs({ bucket, objectPath, contentType, buffer }) { const file = storage.bucket(bucket).file(objectPath); await file.save(buffer, { @@ -38,13 +52,14 @@ export async function uploadToGcs({ bucket, objectPath, contentType, buffer }) { }); } -export async function generateReadSignedUrl({ fileUri, expiresInSeconds }) { - const { bucket, path } = parseGsUri(fileUri); - if (!allowedBuckets().has(bucket)) { - throw new AppError('FORBIDDEN', `Bucket not allowed for signing: ${bucket}`, 403); +export async function generateReadSignedUrl({ fileUri, actorUid, expiresInSeconds }) { + const { bucket, path } = validateFileUriAccess({ fileUri, actorUid }); + const file = storage.bucket(bucket).file(path); + const [exists] = await file.exists(); + if (!exists) { + throw new AppError('NOT_FOUND', 'File not found for signed URL', 404, { fileUri }); } - const file = storage.bucket(bucket).file(path); const expiresAtMs = Date.now() + expiresInSeconds * 1000; const [signedUrl] = await file.getSignedUrl({ version: 'v4', diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index 19845d95..b1cdbc0e 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -1,10 +1,17 @@ -import test from 'node:test'; +import test, { beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import request from 'supertest'; import { createApp } from '../src/app.js'; +import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js'; -process.env.AUTH_BYPASS = 'true'; -process.env.LLM_MOCK = 'true'; +beforeEach(() => { + process.env.AUTH_BYPASS = 'true'; + process.env.LLM_MOCK = 'true'; + process.env.SIGNED_URL_MOCK = 'true'; + process.env.MAX_SIGNED_URL_SECONDS = '900'; + process.env.LLM_RATE_LIMIT_PER_MINUTE = '20'; + __resetLlmRateLimitForTests(); +}); test('GET /healthz returns healthy response', async () => { const app = createApp(); @@ -34,7 +41,7 @@ test('POST /core/create-signed-url returns signed URL', async () => { .post('/core/create-signed-url') .set('Authorization', 'Bearer test-token') .send({ - fileUri: 'gs://krow-workforce-dev-private/foo.pdf', + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/foo.pdf', expiresInSeconds: 300, }); @@ -44,6 +51,35 @@ test('POST /core/create-signed-url returns signed URL', async () => { assert.equal(typeof res.body.requestId, 'string'); }); +test('POST /core/create-signed-url rejects non-owned path', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/create-signed-url') + .set('Authorization', 'Bearer test-token') + .send({ + fileUri: 'gs://krow-workforce-dev-private/uploads/other-user/foo.pdf', + expiresInSeconds: 300, + }); + + assert.equal(res.status, 403); + assert.equal(res.body.code, 'FORBIDDEN'); +}); + +test('POST /core/create-signed-url enforces expiry cap', async () => { + process.env.MAX_SIGNED_URL_SECONDS = '300'; + const app = createApp(); + const res = await request(app) + .post('/core/create-signed-url') + .set('Authorization', 'Bearer test-token') + .send({ + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/foo.pdf', + expiresInSeconds: 301, + }); + + assert.equal(res.status, 400); + assert.equal(res.body.code, 'VALIDATION_ERROR'); +}); + test('POST /invokeLLM legacy alias works', async () => { const app = createApp(); const res = await request(app) @@ -59,3 +95,31 @@ test('POST /invokeLLM legacy alias works', async () => { assert.equal(typeof res.body.result, 'object'); assert.equal(typeof res.body.model, 'string'); }); + +test('POST /core/invoke-llm enforces per-user rate limit', async () => { + process.env.LLM_RATE_LIMIT_PER_MINUTE = '1'; + const app = createApp(); + + const first = await request(app) + .post('/core/invoke-llm') + .set('Authorization', 'Bearer test-token') + .send({ + prompt: 'hello', + responseJsonSchema: { type: 'object' }, + fileUrls: [], + }); + + const second = await request(app) + .post('/core/invoke-llm') + .set('Authorization', 'Bearer test-token') + .send({ + prompt: 'hello again', + responseJsonSchema: { type: 'object' }, + fileUrls: [], + }); + + assert.equal(first.status, 200); + assert.equal(second.status, 429); + assert.equal(second.body.code, 'RATE_LIMITED'); + assert.equal(typeof second.headers['retry-after'], 'string'); +}); diff --git a/makefiles/backend.mk b/makefiles/backend.mk index 77b9d6f7..79b38bf8 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -31,6 +31,8 @@ BACKEND_CORE_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKE BACKEND_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/command-api:latest BACKEND_LOG_LIMIT ?= 100 BACKEND_LLM_MODEL ?= gemini-2.0-flash-001 +BACKEND_MAX_SIGNED_URL_SECONDS ?= 900 +BACKEND_LLM_RATE_LIMIT_PER_MINUTE ?= 20 .PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core @@ -129,7 +131,7 @@ backend-deploy-core: --region=$(BACKEND_REGION) \ --project=$(GCP_PROJECT_ID) \ --service-account=$(BACKEND_RUNTIME_SA_EMAIL) \ - --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000 \ + --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE) \ $(BACKEND_RUN_AUTH_FLAG) @echo "✅ Core backend service deployed." From d5e49ca148e434af1bb190cde87b246a923db04c Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:25:59 -0500 Subject: [PATCH 08/19] docs(m4): add frontend api guide and remove agent tracking files --- CHANGELOG.md | 1 + CLAUDE.md | 129 ------------ GEMINI.md | 138 ------------- docs/MILESTONES/M4/planning/m4-api-catalog.md | 14 +- .../M4/planning/m4-core-api-frontend-guide.md | 188 ++++++++++++++++++ 5 files changed, 200 insertions(+), 270 deletions(-) delete mode 100644 CLAUDE.md delete mode 100644 GEMINI.md create mode 100644 docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c5a013..7d977913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,3 +13,4 @@ | 2026-02-24 | 0.1.8 | Enabled dev frontend reachability and made deploy auth mode environment-aware (`dev` public, `staging` private). | | 2026-02-24 | 0.1.9 | Switched core API from mock behavior to real GCS upload/signed URLs and real Vertex model calls in dev deployment. | | 2026-02-24 | 0.1.10 | Hardened core APIs with signed URL ownership/expiry checks, object existence checks, and per-user LLM rate limiting. | +| 2026-02-24 | 0.1.11 | Added frontend-ready core API guide and linked M4 API catalog to it as source of truth for consumption. | diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 9403874e..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,129 +0,0 @@ -# CLAUDE.md - Project Context for AI Assistants - -This file provides context for Claude Code and other AI assistants working on this codebase. - -## Project Overview - -**KROW Workforce** is a workforce management platform connecting businesses with temporary/on-demand workers. It consists of: -- **Client App**: For businesses to create orders, manage hubs, handle billing -- **Staff App**: For workers to manage availability, clock in/out, view earnings -- **Web Dashboard**: Admin portal (React/Vite - WIP) -- **Backend**: Firebase Data Connect + PostgreSQL on Cloud SQL - -## Monorepo Structure - -``` -/apps - /mobile # Flutter apps (managed by Melos) - /apps - /client # krowwithus_client - Business app - /staff # krowwithus_staff - Worker app - /design_system_viewer - /packages - /core # Base utilities - /domain # Business entities, repository interfaces - /data_connect # Data layer, Firebase Data Connect SDK - /design_system # Shared UI components - /core_localization # i18n (Slang) - /features - /client/* # Client-specific features - /staff/* # Staff-specific features - /web-dashboard # React web app (WIP) -/backend - /dataconnect # GraphQL schemas, Firebase Data Connect config - /cloud-functions # Serverless functions (placeholder) -/internal - /launchpad # Internal DevOps portal - /api-harness # API testing tool -/makefiles # Modular Make targets -/docs # Project documentation -``` - -## Key Commands - -### Mobile Development -```bash -# Install dependencies -make mobile-install - -# Run client app (specify your device ID) -make mobile-client-dev-android DEVICE= - -# Run staff app -make mobile-staff-dev-android DEVICE= - -# Find your device ID -flutter devices - -# Build APK -make mobile-client-build PLATFORM=apk -make mobile-staff-build PLATFORM=apk - -# Code generation (localization + build_runner) -cd apps/mobile && melos run gen:all -``` - -### Web Development -```bash -make install # Install web dependencies -make dev # Run web dev server -``` - -### Data Connect -```bash -make dataconnect-sync # Deploy schemas, migrate, regenerate SDK -``` - -## Architecture Patterns - -- **State Management**: BLoC pattern (flutter_bloc) -- **Navigation**: Flutter Modular -- **Architecture**: Clean Architecture (domain/data/presentation layers) -- **Feature Organization**: Each feature is a separate package -- **Value Objects**: Equatable for entity equality - -## Code Conventions - -- Features go in `/apps/mobile/packages/features/{client|staff}/` -- Shared code goes in `/apps/mobile/packages/{core|domain|data_connect}/` -- UI components go in `/apps/mobile/packages/design_system/` -- GraphQL schemas go in `/backend/dataconnect/schema/` -- Documentation language: **English** - -## Important Files - -- `apps/mobile/melos.yaml` - Melos workspace config -- `makefiles/mobile.mk` - Mobile Make targets -- `backend/dataconnect/dataconnect.yaml` - Data Connect config -- `firebase.json` - Firebase hosting/emulator config -- `BLOCKERS.md` - Known blockers and deviations - -## Branch Protection - -- `main` and `dev` branches are protected -- Always create feature branches: `feature/`, `fix/`, `chore/` -- PRs required for merging - -## Testing Mobile Apps - -1. Connect your Android device or start emulator -2. Run `flutter devices` to get device ID -3. Run `make mobile-client-dev-android DEVICE=` - -## Common Issues - -### "No devices found with name 'android'" -The Makefile defaults to device ID `android`. Override with your actual device: -```bash -make mobile-client-dev-android DEVICE=3fb285a7 -``` - -### Dependency resolution issues -```bash -cd apps/mobile && melos clean && melos bootstrap -``` - -### Code generation out of sync -```bash -cd apps/mobile && melos run gen:all -``` diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 1a9c4fe0..00000000 --- a/GEMINI.md +++ /dev/null @@ -1,138 +0,0 @@ -# GEMINI.md - Project Context for AI Assistants - -This file provides context for Gemini and other AI assistants working on this codebase. - -## Project Overview - -**KROW Workforce** is a workforce management platform connecting businesses with temporary/on-demand workers. It consists of: -- **Client App**: For businesses to create orders, manage hubs, handle billing -- **Staff App**: For workers to manage availability, clock in/out, view earnings -- **Web Dashboard**: Admin portal (React/Vite - WIP) -- **Backend**: Firebase Data Connect + PostgreSQL on Cloud SQL - -## Monorepo Structure - -``` -/apps - /mobile # Flutter apps (managed by Melos) - /apps - /client # krowwithus_client - Business app - /staff # krowwithus_staff - Worker app - /design_system_viewer - /packages - /core # Base utilities - /domain # Business entities, repository interfaces - /data_connect # Data layer, Firebase Data Connect SDK - /design_system # Shared UI components - /core_localization # i18n (Slang) - /features - /client/* # Client-specific features - /staff/* # Staff-specific features - /web-dashboard # React web app (WIP) -/backend - /dataconnect # GraphQL schemas, Firebase Data Connect config - /cloud-functions # Serverless functions (placeholder) -/internal - /launchpad # Internal DevOps portal - /api-harness # API testing tool -/makefiles # Modular Make targets -/docs # Project documentation -/bugs # Bug reports and screenshots -``` - -## Key Commands - -### Mobile Development -```bash -# Install dependencies -make mobile-install - -# Run client app (specify your device ID) -make mobile-client-dev-android DEVICE= - -# Run staff app -make mobile-staff-dev-android DEVICE= - -# Find your device ID -flutter devices - -# Build APK -make mobile-client-build PLATFORM=apk -make mobile-staff-build PLATFORM=apk - -# Code generation (localization + build_runner) -cd apps/mobile && melos run gen:all -``` - -### Web Development -```bash -make install # Install web dependencies -make dev # Run web dev server -``` - -### Data Connect -```bash -make dataconnect-sync # Deploy schemas, migrate, regenerate SDK -``` - -## Architecture Patterns - -- **State Management**: BLoC pattern (flutter_bloc) -- **Navigation**: Flutter Modular -- **Architecture**: Clean Architecture (domain/data/presentation layers) -- **Feature Organization**: Each feature is a separate package -- **Value Objects**: Equatable for entity equality - -## Code Conventions - -- Features go in `/apps/mobile/packages/features/{client|staff}/` -- Shared code goes in `/apps/mobile/packages/{core|domain|data_connect}/` -- UI components go in `/apps/mobile/packages/design_system/` -- GraphQL schemas go in `/backend/dataconnect/schema/` -- Documentation language: **English** - -## Important Files - -- `apps/mobile/melos.yaml` - Melos workspace config -- `makefiles/mobile.mk` - Mobile Make targets -- `backend/dataconnect/dataconnect.yaml` - Data Connect config -- `firebase.json` - Firebase hosting/emulator config -- `BLOCKERS.md` - Known blockers and deviations -- `bugs/BUG-REPORT-*.md` - Bug reports with analysis - -## Branch Protection - -- `main` and `dev` branches are protected -- Always create feature branches: `feature/`, `fix/`, `chore/` -- PRs required for merging - -## Testing Mobile Apps - -1. Connect your Android device or start emulator -2. Run `flutter devices` to get device ID -3. Run `make mobile-client-dev-android DEVICE=` - -## Common Issues - -### "No devices found with name 'android'" -The Makefile defaults to device ID `android`. Override with your actual device: -```bash -make mobile-client-dev-android DEVICE=3fb285a7 -``` - -### Dependency resolution issues -```bash -cd apps/mobile && melos clean && melos bootstrap -``` - -### Code generation out of sync -```bash -cd apps/mobile && melos run gen:all -``` - -## Known Technical Debt - -See `bugs/BUG-REPORT-*.md` for detailed analysis of: -- Authentication/User sync issues -- Error handling architecture (needs AppException pattern) -- BLoC state management patterns (copyWith null handling) diff --git a/docs/MILESTONES/M4/planning/m4-api-catalog.md b/docs/MILESTONES/M4/planning/m4-api-catalog.md index c8e02353..8a4141c9 100644 --- a/docs/MILESTONES/M4/planning/m4-api-catalog.md +++ b/docs/MILESTONES/M4/planning/m4-api-catalog.md @@ -1,10 +1,18 @@ # M4 API Catalog (Implementation Contract) -Status: Draft -Date: 2026-02-24 -Owner: Technical Lead +Status: Active (Planning + Route Inventory) +Date: 2026-02-24 +Owner: Technical Lead Environment: dev +## Frontend source of truth +For frontend implementation, use: +- `docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md` + +Reason: +- this catalog is the broader M4 planning contract +- the frontend guide is the exact deployed request/response contract + ## 1) Scope and purpose This file defines the backend endpoint contract for the M4 foundation build. diff --git a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md new file mode 100644 index 00000000..7525dfdb --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md @@ -0,0 +1,188 @@ +# M4 Core API Frontend Guide (Dev) + +Status: Active +Last updated: 2026-02-24 +Audience: Web and mobile frontend developers + +## 1) Base URLs (dev) +1. Core API: `https://krow-core-api-e3g6witsvq-uc.a.run.app` +2. Command API: `https://krow-command-api-e3g6witsvq-uc.a.run.app` + +## 2) Auth requirements +1. Send Firebase ID token on protected routes: +```http +Authorization: Bearer +``` +2. Health route is public: +- `GET /health` +3. All other routes require Firebase token. + +## 3) Standard error envelope +```json +{ + "code": "STRING_CODE", + "message": "Human readable message", + "details": {}, + "requestId": "uuid" +} +``` + +## 4) Core API endpoints + +## 4.1 Upload file +1. Route: `POST /core/upload-file` +2. Alias: `POST /uploadFile` +3. Content type: `multipart/form-data` +4. Form fields: +- `file` (required) +- `visibility` (optional: `public` or `private`, default `private`) +- `category` (optional) +5. Accepted file types: +- `application/pdf` +- `image/jpeg` +- `image/jpg` +- `image/png` +6. Max upload size: `10 MB` (default) +7. Current behavior: real upload to Cloud Storage (not mock) +8. Success `200` example: +```json +{ + "fileUri": "gs://krow-workforce-dev-private/uploads//173...", + "contentType": "application/pdf", + "size": 12345, + "bucket": "krow-workforce-dev-private", + "path": "uploads//173..._file.pdf", + "requestId": "uuid" +} +``` + +## 4.2 Create signed URL +1. Route: `POST /core/create-signed-url` +2. Alias: `POST /createSignedUrl` +3. Request body: +```json +{ + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "expiresInSeconds": 300 +} +``` +4. Security checks: +- bucket must be allowed (`krow-workforce-dev-public` or `krow-workforce-dev-private`) +- path must be owned by caller (`uploads//...`) +- object must exist +- `expiresInSeconds` must be `<= 900` +5. Success `200` example: +```json +{ + "signedUrl": "https://storage.googleapis.com/...", + "expiresAt": "2026-02-24T15:22:28.105Z", + "requestId": "uuid" +} +``` +6. Typical errors: +- `400 VALIDATION_ERROR` (bad payload or expiry too high) +- `403 FORBIDDEN` (path not owned by caller) +- `404 NOT_FOUND` (object does not exist) + +## 4.3 Invoke model +1. Route: `POST /core/invoke-llm` +2. Alias: `POST /invokeLLM` +3. Request body: +```json +{ + "prompt": "Return JSON with keys summary and risk.", + "responseJsonSchema": { + "type": "object", + "properties": { + "summary": { "type": "string" }, + "risk": { "type": "string" } + }, + "required": ["summary", "risk"] + }, + "fileUrls": [] +} +``` +4. Current behavior: real Vertex model call (not mock) +- model: `gemini-2.0-flash-001` +- timeout: `20 seconds` +5. Rate limit: +- per-user `20 requests/minute` (default) +- on limit: `429 RATE_LIMITED` +- includes `Retry-After` header +6. Success `200` example: +```json +{ + "result": { "summary": "text", "risk": "Low" }, + "model": "gemini-2.0-flash-001", + "latencyMs": 367, + "requestId": "uuid" +} +``` + +## 5) Command API endpoint currently ready for consumption + +## 5.1 Create order command (scaffold) +1. Route: `POST /commands/orders/create` +2. Required headers: +- `Authorization: Bearer ` +- `Idempotency-Key: ` +3. Current behavior: +- validates auth + idempotency +- returns accepted scaffold response +- duplicate key returns the original response payload +4. Success `200` example: +```json +{ + "accepted": true, + "route": "/commands/orders/create", + "commandId": "/commands/orders/create:173...", + "idempotencyKey": "client-key-123", + "requestId": "uuid" +} +``` + +## 6) Frontend fetch examples (web) + +## 6.1 Signed URL request +```ts +const token = await firebaseAuth.currentUser?.getIdToken(); +const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/create-signed-url', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fileUri: 'gs://krow-workforce-dev-private/uploads//file.pdf', + expiresInSeconds: 300, + }), +}); +const data = await res.json(); +``` + +## 6.2 Model request +```ts +const token = await firebaseAuth.currentUser?.getIdToken(); +const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/invoke-llm', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt: 'Return JSON with status.', + responseJsonSchema: { + type: 'object', + properties: { status: { type: 'string' } }, + required: ['status'], + }, + }), +}); +const data = await res.json(); +``` + +## 7) Notes for frontend team +1. Use canonical `/core/*` routes for new work. +2. Aliases exist only for migration compatibility. +3. `requestId` in responses should be logged client-side for debugging. +4. For 429 on model route, retry with exponential backoff and respect `Retry-After`. From 07dd6609d9efaa2a12a76cc44781badaefc9f08a Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:28:44 -0500 Subject: [PATCH 09/19] docs(m4): scope api docs to core endpoints only --- CHANGELOG.md | 1 + docs/MILESTONES/M4/planning/m4-api-catalog.md | 240 ++++++------------ .../M4/planning/m4-core-api-frontend-guide.md | 31 +-- 3 files changed, 81 insertions(+), 191 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d977913..28b961de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,4 @@ | 2026-02-24 | 0.1.9 | Switched core API from mock behavior to real GCS upload/signed URLs and real Vertex model calls in dev deployment. | | 2026-02-24 | 0.1.10 | Hardened core APIs with signed URL ownership/expiry checks, object existence checks, and per-user LLM rate limiting. | | 2026-02-24 | 0.1.11 | Added frontend-ready core API guide and linked M4 API catalog to it as source of truth for consumption. | +| 2026-02-24 | 0.1.12 | Reduced M4 API docs to core-only scope and removed command-route references until command implementation is complete. | diff --git a/docs/MILESTONES/M4/planning/m4-api-catalog.md b/docs/MILESTONES/M4/planning/m4-api-catalog.md index 8a4141c9..25c5293c 100644 --- a/docs/MILESTONES/M4/planning/m4-api-catalog.md +++ b/docs/MILESTONES/M4/planning/m4-api-catalog.md @@ -1,30 +1,26 @@ -# M4 API Catalog (Implementation Contract) +# M4 API Catalog (Core Only) -Status: Active (Planning + Route Inventory) +Status: Active Date: 2026-02-24 Owner: Technical Lead Environment: dev ## Frontend source of truth -For frontend implementation, use: -- `docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md` - -Reason: -- this catalog is the broader M4 planning contract -- the frontend guide is the exact deployed request/response contract +Use this file and `docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md` for core endpoint consumption. ## 1) Scope and purpose -This file defines the backend endpoint contract for the M4 foundation build. +This catalog defines the currently implemented core backend contract for M4. ## 2) Global API rules -1. Canonical route groups: -- `/core/*` for foundational integration routes -- `/commands/*` for business-critical writes -2. Foundation phase security model: -- authenticated user required -- role map enforcement deferred -- policy hook required in handler design -3. Standard error envelope: +1. Route group in scope: `/core/*`. +2. Compatibility aliases in scope: +- `POST /uploadFile` -> `POST /core/upload-file` +- `POST /createSignedUrl` -> `POST /core/create-signed-url` +- `POST /invokeLLM` -> `POST /core/invoke-llm` +3. Auth model: +- `GET /health` is public in dev +- all other routes require `Authorization: Bearer ` +4. Standard error envelope: ```json { "code": "STRING_CODE", @@ -33,100 +29,71 @@ This file defines the backend endpoint contract for the M4 foundation build. "requestId": "optional-request-id" } ``` -4. Required request headers: -- `Authorization: Bearer ` -- `X-Request-Id: ` (optional but recommended) -5. Required response headers: +5. Response header: - `X-Request-Id` -6. Validation: -- all input validated server-side -- reject unknown/invalid fields -7. Logging: -- route -- requestId -- actorId -- latencyMs -- outcome -8. Timeouts and retries: -- command writes must be retry-safe -- use idempotency keys for command write routes -9. Idempotency storage: -- store in Cloud SQL table -- key scope: `userId + route + idempotencyKey` -- key retention: 24 hours -- repeated key returns original response payload -## 3) Compatibility aliases (transition) -1. `POST /uploadFile` -> `POST /core/upload-file` -2. `POST /createSignedUrl` -> `POST /core/create-signed-url` -3. `POST /invokeLLM` -> `POST /core/invoke-llm` +## 3) Core routes -## 4) Rate-limit baseline (initial) -1. `/core/invoke-llm`: 60 requests per minute per user -2. `/core/upload-file`: 30 requests per minute per user -3. `/core/create-signed-url`: 120 requests per minute per user -4. `/commands/*`: 60 requests per minute per user - -## 4.1 Timeout baseline (initial) -1. `/core/invoke-llm`: 20-second hard timeout -2. other `/core/*` routes: 10-second timeout -3. `/commands/*` routes: 15-second timeout - -## 5) Core routes - -## 5.1 Upload file +## 3.1 Upload file 1. Method and route: `POST /core/upload-file` -2. Auth: required -3. Idempotency key: optional -4. Request: multipart form data +2. Request format: `multipart/form-data` +3. Fields: - `file` (required) +- `visibility` (`public` or `private`, optional) - `category` (optional) -- `visibility` (optional: `public` or `private`) -5. Success `200`: +4. Accepted types: +- `application/pdf` +- `image/jpeg` +- `image/jpg` +- `image/png` +5. Max size: `10 MB` (default) +6. Behavior: real upload to Cloud Storage. +7. Success `200`: ```json { - "fileUri": "gs://bucket/path/file.ext", + "fileUri": "gs://krow-workforce-dev-private/uploads//...", "contentType": "application/pdf", "size": 12345, - "bucket": "krow-uploads-private", - "path": "documents/staff/..." + "bucket": "krow-workforce-dev-private", + "path": "uploads//...", + "requestId": "uuid" } ``` -6. Errors: +8. Errors: - `UNAUTHENTICATED` - `INVALID_FILE_TYPE` - `FILE_TOO_LARGE` -- `UPLOAD_FAILED` -## 5.2 Create signed URL +## 3.2 Create signed URL 1. Method and route: `POST /core/create-signed-url` -2. Auth: required -3. Idempotency key: optional -4. Request: +2. Request: ```json { - "fileUri": "gs://bucket/path/file.ext", + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", "expiresInSeconds": 300 } ``` -5. Success `200`: +3. Security checks: +- bucket must be allowed +- path must be owned by caller (`uploads//...`) +- object must exist +- `expiresInSeconds <= 900` +4. Success `200`: ```json { - "signedUrl": "https://...", - "expiresAt": "2026-02-24T15:00:00Z" + "signedUrl": "https://storage.googleapis.com/...", + "expiresAt": "2026-02-24T15:22:28.105Z", + "requestId": "uuid" } ``` -6. Errors: -- `UNAUTHENTICATED` -- `FORBIDDEN_FILE_ACCESS` -- `INVALID_EXPIRES_IN` -- `SIGN_URL_FAILED` +5. Errors: +- `VALIDATION_ERROR` +- `FORBIDDEN` +- `NOT_FOUND` -## 5.3 Invoke model +## 3.3 Invoke model 1. Method and route: `POST /core/invoke-llm` -2. Auth: required -3. Idempotency key: optional -4. Request: +2. Request: ```json { "prompt": "...", @@ -134,103 +101,48 @@ This file defines the backend endpoint contract for the M4 foundation build. "fileUrls": [] } ``` +3. Behavior: +- real Vertex AI call +- model default: `gemini-2.0-flash-001` +- timeout default: `20 seconds` +4. Rate limit: +- `20 requests/minute` per user (default) +- when exceeded: `429 RATE_LIMITED` and `Retry-After` header 5. Success `200`: ```json { "result": {}, - "model": "provider/model-name", - "latencyMs": 980 + "model": "gemini-2.0-flash-001", + "latencyMs": 367, + "requestId": "uuid" } ``` 6. Errors: - `UNAUTHENTICATED` -- `INVALID_SCHEMA` +- `VALIDATION_ERROR` - `MODEL_TIMEOUT` - `MODEL_FAILED` -7. Provider default: -- Vertex AI Gemini +- `RATE_LIMITED` -## 5.4 Health check -1. Method and route: `GET /healthz` -2. Auth: optional (internal policy) -3. Success `200`: +## 3.4 Health +1. Method and route: `GET /health` +2. Success `200`: ```json { "ok": true, - "service": "krow-backend", - "version": "commit-or-tag" + "service": "krow-core-api", + "version": "dev", + "requestId": "uuid" } ``` -## 5.5 Storage bucket policy defaults (dev) -1. Public bucket: `krow-workforce-dev-public` -2. Private bucket: `krow-workforce-dev-private` -3. Private objects are never returned directly; only signed URLs are returned. - -## 6) Command routes (wave 1) - -## 6.1 Create order -1. Method and route: `POST /commands/orders/create` -2. Auth: required -3. Idempotency key: required -4. Purpose: create order + shifts + roles atomically -5. Replaces: -- `apps/web/src/features/operations/orders/components/CreateOrderDialog.tsx` -- `apps/mobile/packages/features/client/home/lib/src/presentation/widgets/shift_order_form_sheet.dart` -- `apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart` - -## 6.2 Update order -1. Method and route: `POST /commands/orders/{orderId}/update` -2. Auth: required -3. Idempotency key: required -4. Purpose: policy-safe multi-entity order update -5. Replaces: -- `apps/web/src/features/operations/orders/EditOrder.tsx` -- `apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart` - -## 6.3 Cancel order -1. Method and route: `POST /commands/orders/{orderId}/cancel` -2. Auth: required -3. Idempotency key: required -4. Purpose: enforce cancellation policy and return explicit conflict code -5. Replaces: -- `apps/web/src/features/operations/orders/OrderDetail.tsx` - -## 6.4 Change shift status -1. Method and route: `POST /commands/shifts/{shiftId}/change-status` -2. Auth: required -3. Idempotency key: required -4. Purpose: enforce state transitions server-side -5. Replaces: -- `apps/web/src/features/operations/tasks/TaskBoard.tsx` - -## 6.5 Assign staff -1. Method and route: `POST /commands/shifts/{shiftId}/assign-staff` -2. Auth: required -3. Idempotency key: required -4. Purpose: assign + count update + conflict checks atomically -5. Replaces: -- `apps/web/src/features/operations/orders/components/AssignStaffModal.tsx` - -## 6.6 Accept shift -1. Method and route: `POST /commands/shifts/{shiftId}/accept` -2. Auth: required -3. Idempotency key: required -4. Purpose: application + counters + rollback-safe behavior in one command -5. Replaces: -- `apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart` - -## 7) Locked defaults before coding starts -1. Idempotency keys are stored in Cloud SQL with 24-hour retention. -2. Request validation library is `zod`. -3. Validation schema location is `backend//src/contracts/`. -4. Storage buckets are: +## 4) Locked defaults +1. Validation library: `zod`. +2. Validation schema location: `backend/core-api/src/contracts/`. +3. Buckets: - `krow-workforce-dev-public` - `krow-workforce-dev-private` -5. Model provider is Vertex AI Gemini with a 20-second timeout for `/core/invoke-llm`. - -## 8) Target response-time objectives (p95) -1. `/healthz` under 200ms -2. `/core/create-signed-url` under 500ms -3. `/commands/*` under 1500ms -4. `/core/invoke-llm` under 15000ms +4. Model provider: Vertex AI Gemini. +5. Max signed URL expiry: `900` seconds. +6. LLM timeout: `20000` ms. +7. LLM rate limit: `20` requests/minute/user. diff --git a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md index 7525dfdb..98e41492 100644 --- a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md +++ b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md @@ -6,7 +6,6 @@ Audience: Web and mobile frontend developers ## 1) Base URLs (dev) 1. Core API: `https://krow-core-api-e3g6witsvq-uc.a.run.app` -2. Command API: `https://krow-command-api-e3g6witsvq-uc.a.run.app` ## 2) Auth requirements 1. Send Firebase ID token on protected routes: @@ -119,31 +118,9 @@ Authorization: Bearer } ``` -## 5) Command API endpoint currently ready for consumption +## 5) Frontend fetch examples (web) -## 5.1 Create order command (scaffold) -1. Route: `POST /commands/orders/create` -2. Required headers: -- `Authorization: Bearer ` -- `Idempotency-Key: ` -3. Current behavior: -- validates auth + idempotency -- returns accepted scaffold response -- duplicate key returns the original response payload -4. Success `200` example: -```json -{ - "accepted": true, - "route": "/commands/orders/create", - "commandId": "/commands/orders/create:173...", - "idempotencyKey": "client-key-123", - "requestId": "uuid" -} -``` - -## 6) Frontend fetch examples (web) - -## 6.1 Signed URL request +## 5.1 Signed URL request ```ts const token = await firebaseAuth.currentUser?.getIdToken(); const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/create-signed-url', { @@ -160,7 +137,7 @@ const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/crea const data = await res.json(); ``` -## 6.2 Model request +## 5.2 Model request ```ts const token = await firebaseAuth.currentUser?.getIdToken(); const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/invoke-llm', { @@ -181,7 +158,7 @@ const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/invo const data = await res.json(); ``` -## 7) Notes for frontend team +## 6) Notes for frontend team 1. Use canonical `/core/*` routes for new work. 2. Aliases exist only for migration compatibility. 3. `requestId` in responses should be logged client-side for debugging. From f2912a1c3209789ab5836a4ddd5b3f6f1fa8f77c Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 24 Feb 2026 11:42:39 -0500 Subject: [PATCH 10/19] docs(m4): add verification architecture contract --- CHANGELOG.md | 1 + docs/MILESTONES/M4/planning/m4-api-catalog.md | 4 + .../M4/planning/m4-core-api-frontend-guide.md | 2 + .../m4-verification-architecture-contract.md | 209 ++++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b961de..4a148057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,3 +15,4 @@ | 2026-02-24 | 0.1.10 | Hardened core APIs with signed URL ownership/expiry checks, object existence checks, and per-user LLM rate limiting. | | 2026-02-24 | 0.1.11 | Added frontend-ready core API guide and linked M4 API catalog to it as source of truth for consumption. | | 2026-02-24 | 0.1.12 | Reduced M4 API docs to core-only scope and removed command-route references until command implementation is complete. | +| 2026-02-24 | 0.1.13 | Added verification architecture contract with endpoint design and workflow split for attire, government ID, and certification. | diff --git a/docs/MILESTONES/M4/planning/m4-api-catalog.md b/docs/MILESTONES/M4/planning/m4-api-catalog.md index 25c5293c..50e88137 100644 --- a/docs/MILESTONES/M4/planning/m4-api-catalog.md +++ b/docs/MILESTONES/M4/planning/m4-api-catalog.md @@ -8,6 +8,10 @@ Environment: dev ## Frontend source of truth Use this file and `docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md` for core endpoint consumption. +## Related next-slice contract +Verification pipeline design (attire, government ID, certification): +- `docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md` + ## 1) Scope and purpose This catalog defines the currently implemented core backend contract for M4. diff --git a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md index 98e41492..ca34b112 100644 --- a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md +++ b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md @@ -163,3 +163,5 @@ const data = await res.json(); 2. Aliases exist only for migration compatibility. 3. `requestId` in responses should be logged client-side for debugging. 4. For 429 on model route, retry with exponential backoff and respect `Retry-After`. +5. Verification workflows (`attire`, `government_id`, `certification`) are defined in: + `docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md`. diff --git a/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md b/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md new file mode 100644 index 00000000..64612e73 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md @@ -0,0 +1,209 @@ +# M4 Verification Architecture Contract (Attire, Government ID, Certification) + +Status: Proposed (next implementation slice) +Date: 2026-02-24 +Owner: Technical Lead + +## 1) Goal +Define a single backend verification pipeline for: +1. `attire` +2. `government_id` +3. `certification` + +This contract gives the team exact endpoint behavior, state flow, and ownership before coding. + +## 2) Principles +1. Upload is evidence intake, not final verification. +2. Verification runs asynchronously in backend workers. +3. Model output is a signal, not legal truth. +4. High-risk identity decisions require stronger validation and human audit trail. +5. Every decision is traceable (`who`, `what`, `when`, `why`). + +## 3) Verification types and policy + +## 3.1 Attire +1. Primary check: vision model + rule checks. +2. Typical output: `AUTO_PASS`, `AUTO_FAIL`, or `NEEDS_REVIEW`. +3. Manual override is always allowed. + +## 3.2 Government ID +1. Required path for mission-critical use: third-party identity verification provider. +2. Model/OCR can pre-parse fields but does not replace identity verification. +3. Final status should require either provider success or manual approval by authorized reviewer. + +## 3.3 Certification +1. Preferred path: verify against issuer API/registry when available. +2. If no issuer API: OCR extraction + manual review. +3. Keep evidence of the source used for validation. + +## 4) State model +1. `PENDING` +2. `PROCESSING` +3. `AUTO_PASS` +4. `AUTO_FAIL` +5. `NEEDS_REVIEW` +6. `APPROVED` +7. `REJECTED` +8. `ERROR` + +Rules: +1. `AUTO_*` and `NEEDS_REVIEW` are machine outcomes. +2. `APPROVED` and `REJECTED` are human outcomes. +3. All transitions are append-only in audit events. + +## 5) API contract + +## 5.1 Create verification job +1. Route: `POST /core/verifications` +2. Auth: required +3. Purpose: enqueue verification job for previously uploaded file. +4. Request: +```json +{ + "type": "attire", + "subjectType": "staff", + "subjectId": "staff_123", + "fileUri": "gs://krow-workforce-dev-private/uploads//item.jpg", + "rules": { + "attireType": "shoes", + "expectedColor": "black" + }, + "metadata": { + "shiftId": "shift_123" + } +} +``` +5. Success `202`: +```json +{ + "verificationId": "ver_123", + "status": "PENDING", + "type": "attire", + "requestId": "uuid" +} +``` + +## 5.2 Get verification status +1. Route: `GET /core/verifications/{verificationId}` +2. Auth: required +3. Purpose: polling from frontend. +4. Success `200`: +```json +{ + "verificationId": "ver_123", + "type": "attire", + "status": "NEEDS_REVIEW", + "confidence": 0.62, + "reasons": ["Color uncertain"], + "extracted": { + "detectedType": "shoe", + "detectedColor": "dark" + }, + "provider": { + "name": "vertex", + "reference": "job_abc" + }, + "review": null, + "createdAt": "2026-02-24T15:00:00Z", + "updatedAt": "2026-02-24T15:00:04Z", + "requestId": "uuid" +} +``` + +## 5.3 Review override +1. Route: `POST /core/verifications/{verificationId}/review` +2. Auth: required (reviewer role later; auth-first now + explicit reviewer id logging) +3. Purpose: final human decision and audit reason. +4. Request: +```json +{ + "decision": "APPROVED", + "note": "Document matches required certification", + "reasonCode": "MANUAL_REVIEW" +} +``` +5. Success `200`: +```json +{ + "verificationId": "ver_123", + "status": "APPROVED", + "review": { + "decision": "APPROVED", + "reviewedBy": "user_456", + "reviewedAt": "2026-02-24T15:02:00Z", + "note": "Document matches required certification", + "reasonCode": "MANUAL_REVIEW" + }, + "requestId": "uuid" +} +``` + +## 5.4 Retry verification job +1. Route: `POST /core/verifications/{verificationId}/retry` +2. Auth: required +3. Purpose: rerun failed or updated checks. +4. Success `202`: status resets to `PENDING`. + +## 6) Worker execution flow +1. API validates payload and ownership of `fileUri`. +2. API writes `verification_jobs` row with `PENDING`. +3. Worker consumes job, marks `PROCESSING`. +4. Worker selects processor by type: +- `attire` -> model + rule scorer +- `government_id` -> provider adapter (+ optional OCR pre-check) +- `certification` -> issuer API adapter or OCR adapter +5. Worker writes machine outcome (`AUTO_PASS`, `AUTO_FAIL`, `NEEDS_REVIEW`, or `ERROR`). +6. Frontend polls status route. +7. Reviewer finalizes with `APPROVED` or `REJECTED` where needed. + +## 7) Data model (minimal) + +## 7.1 Table: `verification_jobs` +1. `id` (pk) +2. `type` (`attire|government_id|certification`) +3. `subject_type`, `subject_id` +4. `owner_uid` +5. `file_uri` +6. `status` +7. `confidence` (nullable) +8. `reasons_json` +9. `extracted_json` +10. `provider_name`, `provider_ref` +11. `created_at`, `updated_at` + +## 7.2 Table: `verification_reviews` +1. `id` (pk) +2. `verification_id` (fk) +3. `decision` (`APPROVED|REJECTED`) +4. `reviewed_by` +5. `note` +6. `reason_code` +7. `reviewed_at` + +## 7.3 Table: `verification_events` +1. `id` (pk) +2. `verification_id` (fk) +3. `from_status`, `to_status` +4. `actor_type` (`system|reviewer`) +5. `actor_id` +6. `details_json` +7. `created_at` + +## 8) Security and compliance notes +1. Restrict verification file paths to owner-owned upload prefixes. +2. Never expose raw private bucket URLs directly. +3. Keep third-party provider secrets in Secret Manager. +4. Log request and decision IDs for every transition. +5. For government ID, keep provider response reference and verification timestamp. + +## 9) Frontend integration pattern +1. Upload file via existing `POST /core/upload-file`. +2. Create verification job with returned `fileUri`. +3. Poll `GET /core/verifications/{id}` until terminal state. +4. Show machine status and confidence. +5. For `NEEDS_REVIEW`, show pending-review UI state. + +## 10) Delivery split (recommended) +1. Wave A (fast): attire verification pipeline end-to-end. +2. Wave B: certification verification with issuer adapter + review. +3. Wave C: government ID provider integration + reviewer flow hardening. From 4a1d5f89e4a16858f628055c9a6426ee21e80cdf Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:29:24 -0500 Subject: [PATCH 11/19] feat(core-api): add verification pipeline with vertex attire adapter --- CHANGELOG.md | 2 + .../src/contracts/core/create-verification.js | 10 + .../src/contracts/core/review-verification.js | 7 + backend/core-api/src/routes/core.js | 91 +++- backend/core-api/src/services/llm.js | 68 ++- backend/core-api/src/services/storage.js | 9 + .../src/services/verification-jobs.js | 510 ++++++++++++++++++ backend/core-api/test/app.test.js | 121 +++++ docs/MILESTONES/M4/planning/m4-api-catalog.md | 82 ++- .../M4/planning/m4-core-api-frontend-guide.md | 80 ++- .../m4-verification-architecture-contract.md | 26 +- makefiles/backend.mk | 4 +- 12 files changed, 997 insertions(+), 13 deletions(-) create mode 100644 backend/core-api/src/contracts/core/create-verification.js create mode 100644 backend/core-api/src/contracts/core/review-verification.js create mode 100644 backend/core-api/src/services/verification-jobs.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a148057..e4e923db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,3 +16,5 @@ | 2026-02-24 | 0.1.11 | Added frontend-ready core API guide and linked M4 API catalog to it as source of truth for consumption. | | 2026-02-24 | 0.1.12 | Reduced M4 API docs to core-only scope and removed command-route references until command implementation is complete. | | 2026-02-24 | 0.1.13 | Added verification architecture contract with endpoint design and workflow split for attire, government ID, and certification. | +| 2026-02-24 | 0.1.14 | Implemented core verification endpoints in dev and updated frontend/API docs with live verification route contracts. | +| 2026-02-24 | 0.1.15 | Added live Vertex Flash Lite attire verification path and third-party adapter scaffolding for government ID and certification checks. | diff --git a/backend/core-api/src/contracts/core/create-verification.js b/backend/core-api/src/contracts/core/create-verification.js new file mode 100644 index 00000000..ee03d8ec --- /dev/null +++ b/backend/core-api/src/contracts/core/create-verification.js @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const createVerificationSchema = z.object({ + type: z.enum(['attire', 'government_id', 'certification']), + subjectType: z.string().min(1).max(80).optional(), + subjectId: z.string().min(1).max(120).optional(), + fileUri: z.string().startsWith('gs://', 'fileUri must start with gs://'), + rules: z.record(z.any()).optional().default({}), + metadata: z.record(z.any()).optional().default({}), +}); diff --git a/backend/core-api/src/contracts/core/review-verification.js b/backend/core-api/src/contracts/core/review-verification.js new file mode 100644 index 00000000..1fb2a56c --- /dev/null +++ b/backend/core-api/src/contracts/core/review-verification.js @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const reviewVerificationSchema = z.object({ + decision: z.enum(['APPROVED', 'REJECTED']), + note: z.string().max(1000).optional().default(''), + reasonCode: z.string().max(100).optional().default('MANUAL_REVIEW'), +}); diff --git a/backend/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js index d73b0170..f9d22ef7 100644 --- a/backend/core-api/src/routes/core.js +++ b/backend/core-api/src/routes/core.js @@ -4,10 +4,23 @@ import { z } from 'zod'; import { AppError } from '../lib/errors.js'; import { requireAuth, requirePolicy } from '../middleware/auth.js'; import { createSignedUrlSchema } from '../contracts/core/create-signed-url.js'; +import { createVerificationSchema } from '../contracts/core/create-verification.js'; import { invokeLlmSchema } from '../contracts/core/invoke-llm.js'; +import { reviewVerificationSchema } from '../contracts/core/review-verification.js'; import { invokeVertexModel } from '../services/llm.js'; import { checkLlmRateLimit } from '../services/llm-rate-limit.js'; -import { generateReadSignedUrl, uploadToGcs, validateFileUriAccess } from '../services/storage.js'; +import { + ensureFileExistsForActor, + generateReadSignedUrl, + uploadToGcs, + validateFileUriAccess, +} from '../services/storage.js'; +import { + createVerificationJob, + getVerificationJob, + retryVerificationJob, + reviewVerificationJob, +} from '../services/verification-jobs.js'; const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_SIGNED_URL_SECONDS = 900; @@ -42,6 +55,10 @@ function useMockUpload() { return process.env.UPLOAD_MOCK !== 'false'; } +function requireVerificationFileExists() { + return process.env.VERIFICATION_REQUIRE_FILE_EXISTS !== 'false'; +} + function parseBody(schema, body) { const parsed = schema.safeParse(body); if (!parsed.success) { @@ -177,12 +194,84 @@ async function handleInvokeLlm(req, res, next) { } } +async function handleCreateVerification(req, res, next) { + try { + const payload = parseBody(createVerificationSchema, req.body || {}); + validateFileUriAccess({ + fileUri: payload.fileUri, + actorUid: req.actor.uid, + }); + + if (requireVerificationFileExists() && !useMockUpload()) { + await ensureFileExistsForActor({ + fileUri: payload.fileUri, + actorUid: req.actor.uid, + }); + } + + const created = createVerificationJob({ + actorUid: req.actor.uid, + payload, + }); + return res.status(202).json({ + ...created, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleGetVerification(req, res, next) { + try { + const verificationId = req.params.verificationId; + const job = getVerificationJob(verificationId, req.actor.uid); + return res.status(200).json({ + ...job, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleReviewVerification(req, res, next) { + try { + const verificationId = req.params.verificationId; + const payload = parseBody(reviewVerificationSchema, req.body || {}); + const updated = reviewVerificationJob(verificationId, req.actor.uid, payload); + return res.status(200).json({ + ...updated, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleRetryVerification(req, res, next) { + try { + const verificationId = req.params.verificationId; + const updated = retryVerificationJob(verificationId, req.actor.uid); + return res.status(202).json({ + ...updated, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + export function createCoreRouter() { const router = Router(); router.post('/upload-file', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleUploadFile); router.post('/create-signed-url', requireAuth, requirePolicy('core.sign-url', 'file'), handleCreateSignedUrl); router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm); + router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification); + router.get('/verifications/:verificationId', requireAuth, requirePolicy('core.verification.read', 'verification'), handleGetVerification); + router.post('/verifications/:verificationId/review', requireAuth, requirePolicy('core.verification.review', 'verification'), handleReviewVerification); + router.post('/verifications/:verificationId/retry', requireAuth, requirePolicy('core.verification.retry', 'verification'), handleRetryVerification); return router; } diff --git a/backend/core-api/src/services/llm.js b/backend/core-api/src/services/llm.js index 31d8b17e..19c90862 100644 --- a/backend/core-api/src/services/llm.js +++ b/backend/core-api/src/services/llm.js @@ -35,14 +35,34 @@ function toTextFromCandidate(candidate) { .trim(); } -export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = [] }) { - const { project, location } = buildVertexConfig(); - const model = process.env.LLM_MODEL || 'gemini-2.0-flash-001'; - const timeoutMs = Number.parseInt(process.env.LLM_TIMEOUT_MS || '20000', 10); +function withJsonSchemaInstruction(prompt, responseJsonSchema) { const schemaText = JSON.stringify(responseJsonSchema); - const fileContext = fileUrls.length > 0 ? `\nFiles:\n${fileUrls.join('\n')}` : ''; - const instruction = `Respond with strict JSON only. Follow this schema exactly:\n${schemaText}`; - const textPrompt = `${prompt}\n\n${instruction}${fileContext}`; + return `${prompt}\n\nRespond with strict JSON only. Follow this schema exactly:\n${schemaText}`; +} + +function guessMimeTypeFromUri(fileUri) { + const path = fileUri.split('?')[0].toLowerCase(); + if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg'; + if (path.endsWith('.png')) return 'image/png'; + if (path.endsWith('.pdf')) return 'application/pdf'; + return 'application/octet-stream'; +} + +function buildMultimodalParts(prompt, fileUris = []) { + const parts = [{ text: prompt }]; + for (const fileUri of fileUris) { + parts.push({ + fileData: { + fileUri, + mimeType: guessMimeTypeFromUri(fileUri), + }, + }); + } + return parts; +} + +async function callVertexJsonModel({ model, timeoutMs, parts }) { + const { project, location } = buildVertexConfig(); const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/publishers/google/models/${model}:generateContent`; const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'], @@ -56,7 +76,7 @@ export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = url, method: 'POST', data: { - contents: [{ role: 'user', parts: [{ text: textPrompt }] }], + contents: [{ role: 'user', parts }], generationConfig: { temperature: 0.2, responseMimeType: 'application/json', @@ -91,3 +111,35 @@ export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = }; } } + +export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = [] }) { + const model = process.env.LLM_MODEL || 'gemini-2.0-flash-001'; + const timeoutMs = Number.parseInt(process.env.LLM_TIMEOUT_MS || '20000', 10); + const promptWithSchema = withJsonSchemaInstruction(prompt, responseJsonSchema); + const fileContext = fileUrls.length > 0 ? `\nFiles:\n${fileUrls.join('\n')}` : ''; + return callVertexJsonModel({ + model, + timeoutMs, + parts: [{ text: `${promptWithSchema}${fileContext}` }], + }); +} + +export async function invokeVertexMultimodalModel({ + prompt, + responseJsonSchema, + fileUris = [], + model, + timeoutMs, +}) { + const resolvedModel = model || process.env.LLM_MODEL || 'gemini-2.0-flash-001'; + const resolvedTimeoutMs = Number.parseInt( + `${timeoutMs || process.env.LLM_TIMEOUT_MS || '20000'}`, + 10 + ); + const promptWithSchema = withJsonSchemaInstruction(prompt, responseJsonSchema); + return callVertexJsonModel({ + model: resolvedModel, + timeoutMs: resolvedTimeoutMs, + parts: buildMultimodalParts(promptWithSchema, fileUris), + }); +} diff --git a/backend/core-api/src/services/storage.js b/backend/core-api/src/services/storage.js index da0dd382..3dcfc2d7 100644 --- a/backend/core-api/src/services/storage.js +++ b/backend/core-api/src/services/storage.js @@ -72,3 +72,12 @@ export async function generateReadSignedUrl({ fileUri, actorUid, expiresInSecond expiresAt: new Date(expiresAtMs).toISOString(), }; } + +export async function ensureFileExistsForActor({ fileUri, actorUid }) { + const { bucket, path } = validateFileUriAccess({ fileUri, actorUid }); + const file = storage.bucket(bucket).file(path); + const [exists] = await file.exists(); + if (!exists) { + throw new AppError('NOT_FOUND', 'Evidence file not found', 404, { fileUri }); + } +} diff --git a/backend/core-api/src/services/verification-jobs.js b/backend/core-api/src/services/verification-jobs.js new file mode 100644 index 00000000..5ffe44bd --- /dev/null +++ b/backend/core-api/src/services/verification-jobs.js @@ -0,0 +1,510 @@ +import crypto from 'node:crypto'; +import { AppError } from '../lib/errors.js'; +import { invokeVertexMultimodalModel } from './llm.js'; + +const jobs = new Map(); + +export const VerificationStatus = Object.freeze({ + PENDING: 'PENDING', + PROCESSING: 'PROCESSING', + AUTO_PASS: 'AUTO_PASS', + AUTO_FAIL: 'AUTO_FAIL', + NEEDS_REVIEW: 'NEEDS_REVIEW', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', + ERROR: 'ERROR', +}); + +const MACHINE_TERMINAL_STATUSES = new Set([ + VerificationStatus.AUTO_PASS, + VerificationStatus.AUTO_FAIL, + VerificationStatus.NEEDS_REVIEW, + VerificationStatus.ERROR, +]); + +const HUMAN_TERMINAL_STATUSES = new Set([ + VerificationStatus.APPROVED, + VerificationStatus.REJECTED, +]); + +function nowIso() { + return new Date().toISOString(); +} + +function accessMode() { + return process.env.VERIFICATION_ACCESS_MODE || 'authenticated'; +} + +function eventRecord({ fromStatus, toStatus, actorType, actorId, details = {} }) { + return { + id: crypto.randomUUID(), + fromStatus, + toStatus, + actorType, + actorId, + details, + createdAt: nowIso(), + }; +} + +function toPublicJob(job) { + return { + verificationId: job.id, + type: job.type, + subjectType: job.subjectType, + subjectId: job.subjectId, + fileUri: job.fileUri, + status: job.status, + confidence: job.confidence, + reasons: job.reasons, + extracted: job.extracted, + provider: job.provider, + review: job.review, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + }; +} + +function assertAccess(job, actorUid) { + if (accessMode() === 'authenticated') { + return; + } + if (job.ownerUid !== actorUid) { + throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403); + } +} + +function requireJob(id) { + const job = jobs.get(id); + if (!job) { + throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId: id }); + } + return job; +} + +function normalizeMachineStatus(status) { + if ( + status === VerificationStatus.AUTO_PASS + || status === VerificationStatus.AUTO_FAIL + || status === VerificationStatus.NEEDS_REVIEW + ) { + return status; + } + return VerificationStatus.NEEDS_REVIEW; +} + +function clampConfidence(value, fallback = 0.5) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + if (parsed < 0) return 0; + if (parsed > 1) return 1; + return parsed; +} + +function asReasonList(reasons, fallback) { + if (Array.isArray(reasons) && reasons.length > 0) { + return reasons.map((item) => `${item}`); + } + return [fallback]; +} + +function providerTimeoutMs() { + return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10); +} + +function attireModel() { + return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001'; +} + +async function runAttireChecks(job) { + if (process.env.VERIFICATION_ATTIRE_AUTOPASS === 'true') { + return { + status: VerificationStatus.AUTO_PASS, + confidence: 0.8, + reasons: ['Auto-pass mode enabled for attire in dev'], + extracted: { + expected: job.rules, + }, + provider: { + name: 'attire-auto-pass', + reference: null, + }, + }; + } + + const attireProvider = process.env.VERIFICATION_ATTIRE_PROVIDER || 'vertex'; + if (attireProvider !== 'vertex') { + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.45, + reasons: [`Attire provider '${attireProvider}' is not supported`], + extracted: { + expected: job.rules, + }, + provider: { + name: attireProvider, + reference: null, + }, + }; + } + + try { + const prompt = [ + 'You are validating worker attire evidence.', + `Rules: ${JSON.stringify(job.rules || {})}`, + 'Return AUTO_PASS only when the image clearly matches required attire.', + 'Return AUTO_FAIL when the image clearly violates required attire.', + 'Return NEEDS_REVIEW when uncertain.', + ].join('\n'); + + const schema = { + type: 'object', + properties: { + status: { type: 'string' }, + confidence: { type: 'number' }, + reasons: { + type: 'array', + items: { type: 'string' }, + }, + extracted: { + type: 'object', + additionalProperties: true, + }, + }, + required: ['status', 'confidence', 'reasons'], + }; + + const modelOutput = await invokeVertexMultimodalModel({ + prompt, + responseJsonSchema: schema, + fileUris: [job.fileUri], + model: attireModel(), + timeoutMs: providerTimeoutMs(), + }); + + const result = modelOutput?.result || {}; + return { + status: normalizeMachineStatus(result.status), + confidence: clampConfidence(result.confidence, 0.6), + reasons: asReasonList(result.reasons, 'Attire check completed'), + extracted: result.extracted || {}, + provider: { + name: 'vertex-attire', + reference: modelOutput?.model || attireModel(), + }, + }; + } catch (error) { + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.35, + reasons: ['Automatic attire check unavailable, manual review required'], + extracted: {}, + provider: { + name: 'vertex-attire', + reference: `error:${error?.code || 'unknown'}`, + }, + }; + } +} + +function getProviderConfig(type) { + if (type === 'government_id') { + return { + name: 'government-id-provider', + url: process.env.VERIFICATION_GOV_ID_PROVIDER_URL, + token: process.env.VERIFICATION_GOV_ID_PROVIDER_TOKEN, + }; + } + return { + name: 'certification-provider', + url: process.env.VERIFICATION_CERT_PROVIDER_URL, + token: process.env.VERIFICATION_CERT_PROVIDER_TOKEN, + }; +} + +async function runThirdPartyChecks(job, type) { + const provider = getProviderConfig(type); + if (!provider.url) { + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.4, + reasons: [`${provider.name} is not configured`], + extracted: {}, + provider: { + name: provider.name, + reference: null, + }, + }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), providerTimeoutMs()); + + try { + const response = await fetch(provider.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(provider.token ? { Authorization: `Bearer ${provider.token}` } : {}), + }, + body: JSON.stringify({ + type, + subjectType: job.subjectType, + subjectId: job.subjectId, + fileUri: job.fileUri, + rules: job.rules, + metadata: job.metadata, + }), + signal: controller.signal, + }); + + const bodyText = await response.text(); + let body = {}; + try { + body = bodyText ? JSON.parse(bodyText) : {}; + } catch { + body = {}; + } + + if (!response.ok) { + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.35, + reasons: [`${provider.name} returned ${response.status}`], + extracted: {}, + provider: { + name: provider.name, + reference: body?.reference || null, + }, + }; + } + + return { + status: normalizeMachineStatus(body.status), + confidence: clampConfidence(body.confidence, 0.6), + reasons: asReasonList(body.reasons, `${provider.name} completed check`), + extracted: body.extracted || {}, + provider: { + name: provider.name, + reference: body.reference || null, + }, + }; + } catch (error) { + const isAbort = error?.name === 'AbortError'; + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.3, + reasons: [ + isAbort + ? `${provider.name} timeout, manual review required` + : `${provider.name} unavailable, manual review required`, + ], + extracted: {}, + provider: { + name: provider.name, + reference: null, + }, + }; + } finally { + clearTimeout(timeout); + } +} + +async function runMachineChecks(job) { + if (job.type === 'attire') { + return runAttireChecks(job); + } + + if (job.type === 'government_id') { + return runThirdPartyChecks(job, 'government_id'); + } + + return runThirdPartyChecks(job, 'certification'); +} + +async function processVerificationJob(id) { + const job = requireJob(id); + if (job.status !== VerificationStatus.PENDING) { + return; + } + + const beforeProcessing = job.status; + job.status = VerificationStatus.PROCESSING; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus: beforeProcessing, + toStatus: VerificationStatus.PROCESSING, + actorType: 'system', + actorId: 'verification-worker', + }) + ); + + try { + const outcome = await runMachineChecks(job); + if (!MACHINE_TERMINAL_STATUSES.has(outcome.status)) { + throw new Error(`Invalid machine outcome status: ${outcome.status}`); + } + const fromStatus = job.status; + job.status = outcome.status; + job.confidence = outcome.confidence; + job.reasons = outcome.reasons; + job.extracted = outcome.extracted; + job.provider = outcome.provider; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus, + toStatus: job.status, + actorType: 'system', + actorId: 'verification-worker', + details: { + confidence: job.confidence, + reasons: job.reasons, + provider: job.provider, + }, + }) + ); + } catch (error) { + const fromStatus = job.status; + job.status = VerificationStatus.ERROR; + job.confidence = null; + job.reasons = [error?.message || 'Verification processing failed']; + job.extracted = {}; + job.provider = { + name: 'verification-worker', + reference: null, + }; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus, + toStatus: VerificationStatus.ERROR, + actorType: 'system', + actorId: 'verification-worker', + details: { + error: error?.message || 'Verification processing failed', + }, + }) + ); + } +} + +function queueVerificationProcessing(id) { + setTimeout(() => { + processVerificationJob(id).catch(() => {}); + }, 0); +} + +export function createVerificationJob({ actorUid, payload }) { + const now = nowIso(); + const id = `ver_${crypto.randomUUID()}`; + const job = { + id, + type: payload.type, + subjectType: payload.subjectType || null, + subjectId: payload.subjectId || null, + ownerUid: actorUid, + fileUri: payload.fileUri, + rules: payload.rules || {}, + metadata: payload.metadata || {}, + status: VerificationStatus.PENDING, + confidence: null, + reasons: [], + extracted: {}, + provider: null, + review: null, + createdAt: now, + updatedAt: now, + events: [ + eventRecord({ + fromStatus: null, + toStatus: VerificationStatus.PENDING, + actorType: 'system', + actorId: actorUid, + }), + ], + }; + jobs.set(id, job); + queueVerificationProcessing(id); + return toPublicJob(job); +} + +export function getVerificationJob(verificationId, actorUid) { + const job = requireJob(verificationId); + assertAccess(job, actorUid); + return toPublicJob(job); +} + +export function reviewVerificationJob(verificationId, actorUid, review) { + const job = requireJob(verificationId); + assertAccess(job, actorUid); + + if (HUMAN_TERMINAL_STATUSES.has(job.status)) { + throw new AppError('CONFLICT', 'Verification already finalized', 409, { + verificationId, + status: job.status, + }); + } + + const fromStatus = job.status; + job.status = review.decision; + job.review = { + decision: review.decision, + reviewedBy: actorUid, + reviewedAt: nowIso(), + note: review.note || '', + reasonCode: review.reasonCode || 'MANUAL_REVIEW', + }; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus, + toStatus: job.status, + actorType: 'reviewer', + actorId: actorUid, + details: { + reasonCode: job.review.reasonCode, + }, + }) + ); + + return toPublicJob(job); +} + +export function retryVerificationJob(verificationId, actorUid) { + const job = requireJob(verificationId); + assertAccess(job, actorUid); + + if (job.status === VerificationStatus.PROCESSING) { + throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { + verificationId, + }); + } + + const fromStatus = job.status; + job.status = VerificationStatus.PENDING; + job.confidence = null; + job.reasons = []; + job.extracted = {}; + job.provider = null; + job.review = null; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus, + toStatus: VerificationStatus.PENDING, + actorType: 'reviewer', + actorId: actorUid, + details: { + retried: true, + }, + }) + ); + queueVerificationProcessing(verificationId); + return toPublicJob(job); +} + +export function __resetVerificationJobsForTests() { + jobs.clear(); +} diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index b1cdbc0e..e12f9005 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -3,16 +3,42 @@ import assert from 'node:assert/strict'; import request from 'supertest'; import { createApp } from '../src/app.js'; import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js'; +import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js'; beforeEach(() => { process.env.AUTH_BYPASS = 'true'; process.env.LLM_MOCK = 'true'; process.env.SIGNED_URL_MOCK = 'true'; + process.env.UPLOAD_MOCK = 'true'; process.env.MAX_SIGNED_URL_SECONDS = '900'; process.env.LLM_RATE_LIMIT_PER_MINUTE = '20'; + process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false'; + process.env.VERIFICATION_ACCESS_MODE = 'authenticated'; + process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock'; __resetLlmRateLimitForTests(); + __resetVerificationJobsForTests(); }); +async function waitForMachineStatus(app, verificationId, maxAttempts = 30) { + let last; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + last = await request(app) + .get(`/core/verifications/${verificationId}`) + .set('Authorization', 'Bearer test-token'); + if ( + last.body?.status === 'AUTO_PASS' + || last.body?.status === 'AUTO_FAIL' + || last.body?.status === 'NEEDS_REVIEW' + || last.body?.status === 'ERROR' + ) { + return last; + } + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return last; +} + test('GET /healthz returns healthy response', async () => { const app = createApp(); const res = await request(app).get('/healthz'); @@ -123,3 +149,98 @@ test('POST /core/invoke-llm enforces per-user rate limit', async () => { assert.equal(second.body.code, 'RATE_LIMITED'); assert.equal(typeof second.headers['retry-after'], 'string'); }); + +test('POST /core/verifications creates async job and GET returns status', async () => { + const app = createApp(); + const created = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'attire', + subjectType: 'staff', + subjectId: 'staff_1', + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/attire.jpg', + rules: { attireType: 'shoes', expectedColor: 'black' }, + }); + + assert.equal(created.status, 202); + assert.equal(created.body.type, 'attire'); + assert.equal(created.body.status, 'PENDING'); + assert.equal(typeof created.body.verificationId, 'string'); + + const status = await waitForMachineStatus(app, created.body.verificationId); + assert.equal(status.status, 200); + assert.equal(status.body.verificationId, created.body.verificationId); + assert.equal(status.body.type, 'attire'); + assert.ok(['NEEDS_REVIEW', 'AUTO_PASS', 'AUTO_FAIL', 'ERROR'].includes(status.body.status)); +}); + +test('POST /core/verifications rejects file paths not owned by actor', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'attire', + fileUri: 'gs://krow-workforce-dev-private/uploads/other-user/not-allowed.jpg', + rules: { attireType: 'shoes' }, + }); + + assert.equal(res.status, 403); + assert.equal(res.body.code, 'FORBIDDEN'); +}); + +test('POST /core/verifications/:id/review finalizes verification', async () => { + const app = createApp(); + const created = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'certification', + subjectType: 'staff', + subjectId: 'staff_1', + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/cert.pdf', + rules: { certType: 'food_safety' }, + }); + + const status = await waitForMachineStatus(app, created.body.verificationId); + assert.equal(status.status, 200); + + const reviewed = await request(app) + .post(`/core/verifications/${created.body.verificationId}/review`) + .set('Authorization', 'Bearer test-token') + .send({ + decision: 'APPROVED', + note: 'Looks good', + reasonCode: 'MANUAL_REVIEW', + }); + + assert.equal(reviewed.status, 200); + assert.equal(reviewed.body.status, 'APPROVED'); + assert.equal(reviewed.body.review.decision, 'APPROVED'); +}); + +test('POST /core/verifications/:id/retry requeues verification', async () => { + const app = createApp(); + const created = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'government_id', + subjectType: 'staff', + subjectId: 'staff_1', + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/id-front.jpg', + rules: {}, + }); + + const status = await waitForMachineStatus(app, created.body.verificationId); + assert.equal(status.status, 200); + + const retried = await request(app) + .post(`/core/verifications/${created.body.verificationId}/retry`) + .set('Authorization', 'Bearer test-token') + .send({}); + + assert.equal(retried.status, 202); + assert.equal(retried.body.status, 'PENDING'); +}); diff --git a/docs/MILESTONES/M4/planning/m4-api-catalog.md b/docs/MILESTONES/M4/planning/m4-api-catalog.md index 50e88137..516ebf38 100644 --- a/docs/MILESTONES/M4/planning/m4-api-catalog.md +++ b/docs/MILESTONES/M4/planning/m4-api-catalog.md @@ -128,7 +128,83 @@ This catalog defines the currently implemented core backend contract for M4. - `MODEL_FAILED` - `RATE_LIMITED` -## 3.4 Health +## 3.4 Create verification job +1. Method and route: `POST /core/verifications` +2. Auth: required +3. Request: +```json +{ + "type": "attire", + "subjectType": "worker", + "subjectId": "worker_123", + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "rules": {} +} +``` +4. Behavior: +- validates `fileUri` ownership +- requires file existence when `UPLOAD_MOCK=false` and `VERIFICATION_REQUIRE_FILE_EXISTS=true` +- enqueues async verification +5. Success `202`: +```json +{ + "verificationId": "ver_123", + "status": "PENDING", + "type": "attire", + "requestId": "uuid" +} +``` +6. Errors: +- `UNAUTHENTICATED` +- `VALIDATION_ERROR` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.5 Get verification status +1. Method and route: `GET /core/verifications/{verificationId}` +2. Auth: required +3. Success `200`: +```json +{ + "verificationId": "ver_123", + "status": "NEEDS_REVIEW", + "type": "attire", + "requestId": "uuid" +} +``` +4. Errors: +- `UNAUTHENTICATED` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.6 Review verification +1. Method and route: `POST /core/verifications/{verificationId}/review` +2. Auth: required +3. Request: +```json +{ + "decision": "APPROVED", + "note": "Manual review passed", + "reasonCode": "MANUAL_REVIEW" +} +``` +4. Success `200`: status becomes `APPROVED` or `REJECTED`. +5. Errors: +- `UNAUTHENTICATED` +- `VALIDATION_ERROR` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.7 Retry verification +1. Method and route: `POST /core/verifications/{verificationId}/retry` +2. Auth: required +3. Success `202`: status resets to `PENDING`. +4. Errors: +- `UNAUTHENTICATED` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.8 Health 1. Method and route: `GET /health` 2. Success `200`: ```json @@ -150,3 +226,7 @@ This catalog defines the currently implemented core backend contract for M4. 5. Max signed URL expiry: `900` seconds. 6. LLM timeout: `20000` ms. 7. LLM rate limit: `20` requests/minute/user. +8. Verification access mode default: `authenticated`. +9. Verification file existence check default: enabled (`VERIFICATION_REQUIRE_FILE_EXISTS=true`). +10. Verification attire provider default in dev: `vertex` with model `gemini-2.0-flash-lite-001`. +11. Verification government/certification providers: external adapters via configured provider URL/token. diff --git a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md index ca34b112..64f8a5c2 100644 --- a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md +++ b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md @@ -118,6 +118,82 @@ Authorization: Bearer } ``` +## 4.4 Create verification job +1. Route: `POST /core/verifications` +2. Auth: required +3. Purpose: enqueue an async verification job for an uploaded file. +4. Request body: +```json +{ + "type": "attire", + "subjectType": "worker", + "subjectId": "", + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "rules": { + "dressCode": "black shoes" + } +} +``` +5. Success `202` example: +```json +{ + "verificationId": "ver_123", + "status": "PENDING", + "type": "attire", + "requestId": "uuid" +} +``` +6. Current machine processing behavior in dev: +- `attire`: live vision check using Vertex Gemini Flash Lite model. +- `government_id`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). +- `certification`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). + +## 4.5 Get verification status +1. Route: `GET /core/verifications/{verificationId}` +2. Auth: required +3. Purpose: polling status from frontend. +4. Success `200` example: +```json +{ + "verificationId": "ver_123", + "status": "NEEDS_REVIEW", + "type": "attire", + "review": null, + "requestId": "uuid" +} +``` + +## 4.6 Review verification +1. Route: `POST /core/verifications/{verificationId}/review` +2. Auth: required +3. Purpose: final human decision for the verification. +4. Request body: +```json +{ + "decision": "APPROVED", + "note": "Manual review passed", + "reasonCode": "MANUAL_REVIEW" +} +``` +5. Success `200` example: +```json +{ + "verificationId": "ver_123", + "status": "APPROVED", + "review": { + "decision": "APPROVED", + "reviewedBy": "" + }, + "requestId": "uuid" +} +``` + +## 4.7 Retry verification +1. Route: `POST /core/verifications/{verificationId}/retry` +2. Auth: required +3. Purpose: requeue verification to run again. +4. Success `202` example: status resets to `PENDING`. + ## 5) Frontend fetch examples (web) ## 5.1 Signed URL request @@ -163,5 +239,7 @@ const data = await res.json(); 2. Aliases exist only for migration compatibility. 3. `requestId` in responses should be logged client-side for debugging. 4. For 429 on model route, retry with exponential backoff and respect `Retry-After`. -5. Verification workflows (`attire`, `government_id`, `certification`) are defined in: +5. Verification routes are now available in dev under `/core/verifications*`. +6. Current verification processing is async and returns machine statuses first (`PENDING`, `PROCESSING`, `NEEDS_REVIEW`, etc.). +7. Full verification design and policy details: `docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md`. diff --git a/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md b/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md index 64612e73..59731ea3 100644 --- a/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md +++ b/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md @@ -1,9 +1,20 @@ # M4 Verification Architecture Contract (Attire, Government ID, Certification) -Status: Proposed (next implementation slice) +Status: Partially implemented in dev (core endpoints + async in-memory processor) Date: 2026-02-24 Owner: Technical Lead +## Implementation status today (dev) +1. Implemented routes: +- `POST /core/verifications` +- `GET /core/verifications/{verificationId}` +- `POST /core/verifications/{verificationId}/review` +- `POST /core/verifications/{verificationId}/retry` +2. Current processor is in-memory and non-persistent (for fast frontend integration in dev). +3. Next hardening step is persistent job storage and worker execution before staging. +4. Attire uses a live Vertex vision model path with `gemini-2.0-flash-lite-001` by default. +5. Government ID and certification use third-party adapter contracts (provider URL/token envs) and fall back to `NEEDS_REVIEW` when providers are not configured. + ## 1) Goal Define a single backend verification pipeline for: 1. `attire` @@ -196,6 +207,19 @@ Rules: 4. Log request and decision IDs for every transition. 5. For government ID, keep provider response reference and verification timestamp. +## 11) Provider configuration (environment variables) +1. Attire model: +- `VERIFICATION_ATTIRE_PROVIDER=vertex` +- `VERIFICATION_ATTIRE_MODEL=gemini-2.0-flash-lite-001` +2. Government ID provider: +- `VERIFICATION_GOV_ID_PROVIDER_URL` +- `VERIFICATION_GOV_ID_PROVIDER_TOKEN` (Secret Manager recommended) +3. Certification provider: +- `VERIFICATION_CERT_PROVIDER_URL` +- `VERIFICATION_CERT_PROVIDER_TOKEN` (Secret Manager recommended) +4. Provider timeout: +- `VERIFICATION_PROVIDER_TIMEOUT_MS` (default `8000`) + ## 9) Frontend integration pattern 1. Upload file via existing `POST /core/upload-file`. 2. Create verification job with returned `fileUri`. diff --git a/makefiles/backend.mk b/makefiles/backend.mk index 79b38bf8..5ee113c0 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -31,6 +31,8 @@ BACKEND_CORE_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKE BACKEND_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/command-api:latest BACKEND_LOG_LIMIT ?= 100 BACKEND_LLM_MODEL ?= gemini-2.0-flash-001 +BACKEND_VERIFICATION_ATTIRE_MODEL ?= gemini-2.0-flash-lite-001 +BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS ?= 8000 BACKEND_MAX_SIGNED_URL_SECONDS ?= 900 BACKEND_LLM_RATE_LIMIT_PER_MINUTE ?= 20 @@ -131,7 +133,7 @@ backend-deploy-core: --region=$(BACKEND_REGION) \ --project=$(GCP_PROJECT_ID) \ --service-account=$(BACKEND_RUNTIME_SA_EMAIL) \ - --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE) \ + --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE),VERIFICATION_ACCESS_MODE=authenticated,VERIFICATION_REQUIRE_FILE_EXISTS=true,VERIFICATION_ATTIRE_PROVIDER=vertex,VERIFICATION_ATTIRE_MODEL=$(BACKEND_VERIFICATION_ATTIRE_MODEL),VERIFICATION_PROVIDER_TIMEOUT_MS=$(BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS) \ $(BACKEND_RUN_AUTH_FLAG) @echo "✅ Core backend service deployed." From a07ee75dc9f5d66fde072f348c27a8c7e275f34d Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:45:24 -0500 Subject: [PATCH 12/19] docs(m4): align verification contract naming and section order --- .../planning/m4-verification-architecture-contract.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md b/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md index 59731ea3..31ea59b2 100644 --- a/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md +++ b/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md @@ -111,8 +111,8 @@ Rules: "detectedColor": "dark" }, "provider": { - "name": "vertex", - "reference": "job_abc" + "name": "vertex-attire", + "reference": "gemini-2.0-flash-lite-001" }, "review": null, "createdAt": "2026-02-24T15:00:00Z", @@ -207,7 +207,7 @@ Rules: 4. Log request and decision IDs for every transition. 5. For government ID, keep provider response reference and verification timestamp. -## 11) Provider configuration (environment variables) +## 9) Provider configuration (environment variables) 1. Attire model: - `VERIFICATION_ATTIRE_PROVIDER=vertex` - `VERIFICATION_ATTIRE_MODEL=gemini-2.0-flash-lite-001` @@ -220,14 +220,14 @@ Rules: 4. Provider timeout: - `VERIFICATION_PROVIDER_TIMEOUT_MS` (default `8000`) -## 9) Frontend integration pattern +## 10) Frontend integration pattern 1. Upload file via existing `POST /core/upload-file`. 2. Create verification job with returned `fileUri`. 3. Poll `GET /core/verifications/{id}` until terminal state. 4. Show machine status and confidence. 5. For `NEEDS_REVIEW`, show pending-review UI state. -## 10) Delivery split (recommended) +## 11) Delivery split (recommended) 1. Wave A (fast): attire verification pipeline end-to-end. 2. Wave B: certification verification with issuer adapter + review. 3. Wave C: government ID provider integration + reviewer flow hardening. From f4f002b41b541af5cd95cbf31572ed050aa1ada5 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Tue, 24 Feb 2026 15:52:04 -0500 Subject: [PATCH 13/19] docs(m4): define multi-tenant schema and phased rbac rollout --- CHANGELOG.md | 3 + .../M4/planning/m4-target-schema-blueprint.md | 390 ++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e4e923db..b29789f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,3 +18,6 @@ | 2026-02-24 | 0.1.13 | Added verification architecture contract with endpoint design and workflow split for attire, government ID, and certification. | | 2026-02-24 | 0.1.14 | Implemented core verification endpoints in dev and updated frontend/API docs with live verification route contracts. | | 2026-02-24 | 0.1.15 | Added live Vertex Flash Lite attire verification path and third-party adapter scaffolding for government ID and certification checks. | +| 2026-02-24 | 0.1.16 | Added M4 target schema blueprint doc with first-principles modular model, constraints, and migration phases. | +| 2026-02-24 | 0.1.17 | Added full current-schema mermaid model relationship map to the M4 target schema blueprint. | +| 2026-02-24 | 0.1.18 | Updated schema blueprint with explicit multi-tenant stakeholder model and phased RBAC rollout with shadow mode before enforcement. | diff --git a/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md b/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md new file mode 100644 index 00000000..5a709ad9 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md @@ -0,0 +1,390 @@ +# M4 Target Schema Blueprint (Command-Ready) + +Status: Draft for team alignment +Date: 2026-02-24 +Owner: Technical Lead + +## 1) Goal +Define the target database shape we want **before** command-backend implementation, so critical flows are atomic, secure, and scalable. + +## 1.1 Stakeholder and tenancy model +This product should be designed as a **multi-tenant platform**. + +1. Tenant: +- One staffing company account (example: Legendary Event Staffing and Entertainment). +2. Business: +- A customer/client account owned by a tenant. +3. User: +- A human identity (auth account) that can belong to one or more tenants. +4. Staff: +- A workforce profile linked to a user identity and tenant-scoped operations. + +Practical meaning: +1. The same platform can serve multiple staffing companies safely. +2. Data isolation is by `tenant_id`, not only by business/vendor IDs. +3. Not every record starts as a full active user: +- invite-first or pending onboarding records are valid, +- then bound to `user_id` when activation is completed. + +```mermaid +flowchart LR + U["User identity"] --> M["Tenant membership"] + M --> T["Tenant staffing company"] + T --> B["Business client"] + T --> V["Vendor partner"] + B --> O["Orders and shifts"] + V --> O +``` + +## 2) First-principles rules +1. Every critical write must be server-mediated and transactional. +2. Tenant boundaries must be explicit in data and queries. +3. Money and rates must use exact numeric types, not floating point. +4. Data needed for constraints should be relational, not hidden in JSON blobs. +5. Every high-risk state transition must be auditable and replayable. + +## 3) Current anti-patterns we are removing +1. Direct client mutation of core entities. +2. Broad `USER`-auth CRUD without strict tenant scoping. +3. Financial values as `Float`. +4. Core workflow state embedded in generic `Any/jsonb` fields. +5. Missing uniqueness/index constraints on high-traffic paths. + +## 4) Target modular schema + +## 4.1 Identity and Access +Tables: +1. `users` (source identity, profile, auth linkage) +2. `tenant_memberships` (new; membership + base access per tenant) +3. `team_members` (membership + scope per team) +4. `roles` (new) +5. `permissions` (new) +6. `role_bindings` (new; who has which role in which scope) + +Rules: +1. Unique tenant membership: `(tenant_id, user_id)`. +2. Unique team membership: `(team_id, user_id)`. +3. Access checks resolve through tenant membership first, then optional team/hub scope. + +## 4.2 Organization and Tenant +Tables: +1. `tenants` (new canonical boundary: business/vendor ownership root) +2. `businesses` +3. `vendors` +4. `teams` +5. `team_hubs` +6. `hubs` + +Rules: +1. Every command-critical row references `tenant_id`. +2. All list queries must include tenant predicate. + +## 4.8 RBAC rollout strategy (deferred enforcement) +RBAC should be introduced in phases and **not enforced everywhere immediately**. + +Phase A: Auth-first (now) +1. Require valid auth token. +2. Resolve tenant context. +3. Allow current work to continue while logging actor + tenant + action. + +Phase B: Shadow RBAC +1. Evaluate permissions (`allow`/`deny`) in backend. +2. Log decisions but do not block most requests yet. +3. Start with warnings and dashboards for denied actions. + +Phase C: Enforced RBAC on command writes +1. Enforce RBAC on `/commands/*` only. +2. Keep low-risk read flows in transition mode. + +Phase D: Enforced RBAC on high-risk reads +1. Enforce tenant and role checks on sensitive read connectors. +2. Remove remaining broad user-level access. + +```mermaid +flowchart LR + A["Auth only"] --> B["Shadow RBAC logging"] + B --> C["Enforce RBAC on command writes"] + C --> D["Enforce RBAC on sensitive reads"] +``` + +## 4.3 Scheduling and Orders +Tables: +1. `orders` +2. `order_schedule_rules` (new; replaces schedule JSON fields) +3. `shifts` +4. `shift_roles` +5. `shift_role_requirements` (optional extension for policy rules) +6. `shift_managers` (new; replaces `managers: [Any!]`) + +Rules: +1. No denormalized `assignedStaff` or `shifts` JSON in `orders`. +2. Time constraints: `start_time < end_time`. +3. Capacity constraints: `assigned <= count`, `filled <= workers_needed`. +4. Canonical status names (single spelling across schema). + +## 4.4 Staffing and Matching +Tables: +1. `staffs` +2. `staff_roles` +3. `workforce` +4. `applications` +5. `assignments` + +Rules: +1. One active workforce relation per `(vendor_id, staff_id)`. +2. One application per `(shift_id, role_id, staff_id)` unless versioned intentionally. +3. Assignment state transitions only through command APIs. + +## 4.5 Compliance and Verification +Tables: +1. `documents` +2. `staff_documents` +3. `certificates` +4. `verification_jobs` +5. `verification_reviews` +6. `verification_events` + +Rules: +1. Verification is asynchronous and append-only for events. +2. Manual review is explicit and tracked. +3. Government ID and certification provider references are persisted. + +## 4.6 Financial and Payout +Tables: +1. `invoices` +2. `invoice_templates` +3. `recent_payments` +4. `accounts` (refactor to tokenized provider references) + +Rules: +1. Replace monetary `Float` with exact numeric (`DECIMAL(12,2)` or integer cents). +2. Do not expose raw account/routing values in query connectors. +3. Add one-primary-account constraint per owner. + +## 4.7 Audit and Reliability +Tables: +1. `domain_events` (new) +2. `idempotency_keys` (already started in command API SQL) +3. `activity_logs` + +Rules: +1. Every command write emits a domain event. +2. Idempotency scope: `(actor_uid, route, idempotency_key)`. + +## 5) Target core model (conceptual) + +```mermaid +erDiagram + TENANT ||--o{ BUSINESS : owns + TENANT ||--o{ VENDOR : owns + TENANT ||--o{ TEAM : owns + TEAM ||--o{ TEAM_MEMBER : has + USER ||--o{ TEAM_MEMBER : belongs_to + + BUSINESS ||--o{ ORDER : requests + VENDOR ||--o{ ORDER : fulfills + ORDER ||--o{ ORDER_SCHEDULE_RULE : has + ORDER ||--o{ SHIFT : expands_to + SHIFT ||--o{ SHIFT_ROLE : requires + SHIFT ||--o{ SHIFT_MANAGER : has + + USER ||--o{ STAFF : identity + STAFF ||--o{ STAFF_ROLE : skills + VENDOR ||--o{ WORKFORCE : contracts + STAFF ||--o{ WORKFORCE : linked + SHIFT_ROLE ||--o{ APPLICATION : receives + STAFF ||--o{ APPLICATION : applies + SHIFT_ROLE ||--o{ ASSIGNMENT : allocates + WORKFORCE ||--o{ ASSIGNMENT : executes + + STAFF ||--o{ CERTIFICATE : has + STAFF ||--o{ STAFF_DOCUMENT : uploads + DOCUMENT ||--o{ STAFF_DOCUMENT : references + STAFF ||--o{ VERIFICATION_JOB : subject + VERIFICATION_JOB ||--o{ VERIFICATION_REVIEW : reviewed_by + VERIFICATION_JOB ||--o{ VERIFICATION_EVENT : logs + + ORDER ||--o{ INVOICE : billed_by + INVOICE ||--o{ RECENT_PAYMENT : settles + TENANT ||--o{ ACCOUNT_TOKEN_REF : payout_method + + ORDER ||--o{ DOMAIN_EVENT : emits + SHIFT ||--o{ DOMAIN_EVENT : emits + ASSIGNMENT ||--o{ DOMAIN_EVENT : emits +``` + +## 6) Command write boundary on this schema + +```mermaid +flowchart LR + A["Frontend app"] --> B["Command API"] + B --> C["Policy + validation"] + C --> D["Single database transaction"] + D --> E["orders, shifts, shift_roles, applications, assignments"] + D --> F["domain_events + idempotency_keys"] + E --> G["Read models and reports"] +``` + +## 7) Minimum constraints and indexes to add before command build + +## 7.1 Constraints +1. `shift_roles`: check `assigned >= 0 AND assigned <= count`. +2. `shifts`: check `start_time < end_time`. +3. `applications`: unique `(shift_id, role_id, staff_id)`. +4. `workforce`: unique active `(vendor_id, staff_id)`. +5. `team_members`: unique `(team_id, user_id)`. +6. `accounts` (or token ref table): unique primary per owner. + +## 7.2 Indexes +1. `orders (tenant_id, status, date)`. +2. `shifts (order_id, date, status)`. +3. `shift_roles (shift_id, role_id, start_time)`. +4. `applications (shift_id, role_id, status, created_at)`. +5. `assignments (workforce_id, shift_id, role_id, status)`. +6. `verification_jobs (subject_id, type, status, created_at)`. +7. `invoices (business_id, vendor_id, status, due_date)`. + +## 8) Data type normalization +1. Monetary: `Float -> DECIMAL(12,2)` (or integer cents). +2. Generic JSON fields in core scheduling: split into relational tables. +3. Timestamps: store UTC and enforce server-generated creation/update fields. + +## 9) Security boundary in schema/connectors +1. Remove broad list queries for sensitive entities unless tenant-scoped. +2. Strip sensitive fields from connector query payloads (bank/routing). +3. Keep high-risk mutations behind command API; Data Connect remains read-first for client. + +## 10) Migration phases (schema-first) + +```mermaid +flowchart TD + P0["Phase 0: Safety patch + - lock sensitive fields + - enforce tenant-scoped queries + - freeze new direct write connectors"] --> P1["Phase 1: Core constraints + - add unique/check constraints + - add indexes + - normalize money types"] + P1 --> P2["Phase 2: Tenant and RBAC base tables + - add tenants and tenant_memberships + - add roles permissions role_bindings + - run RBAC in shadow mode"] + P2 --> P3["Phase 3: Scheduling normalization + - remove order JSON workflow fields + - add order_schedule_rules and shift_managers"] + P3 --> P4["Phase 4: Command rollout + - command writes on hardened schema + - emit domain events + idempotency + - enforce RBAC for command routes"] + P4 --> P5["Phase 5: Read migration + cleanup + - migrate frontend reads as needed + - enforce RBAC for sensitive reads + - retire deprecated connectors"] +``` + +## 11) Definition of ready for command backend +1. P0 and P1 complete in `dev`. +2. Tenant scoping verified in connector tests. +3. Sensitive field exposure removed. +4. Core transaction invariants enforced by schema constraints. +5. Command API contracts mapped to new normalized tables. +6. RBAC is in shadow mode with decision logs in place (not hard-blocking yet). + +## 12) Full current model relationship map (all models) + +```mermaid +flowchart LR + Account["Account"] + ActivityLog["ActivityLog"] + Application["Application"] + Assignment["Assignment"] + AttireOption["AttireOption"] + BenefitsData["BenefitsData"] + Business["Business"] + Category["Category"] + Certificate["Certificate"] + ClientFeedback["ClientFeedback"] + Conversation["Conversation"] + Course["Course"] + CustomRateCard["CustomRateCard"] + Document["Document"] + EmergencyContact["EmergencyContact"] + FaqData["FaqData"] + Hub["Hub"] + Invoice["Invoice"] + InvoiceTemplate["InvoiceTemplate"] + Level["Level"] + MemberTask["MemberTask"] + Message["Message"] + Order["Order"] + RecentPayment["RecentPayment"] + Role["Role"] + RoleCategory["RoleCategory"] + Shift["Shift"] + ShiftRole["ShiftRole"] + Staff["Staff"] + StaffAvailability["StaffAvailability"] + StaffAvailabilityStats["StaffAvailabilityStats"] + StaffCourse["StaffCourse"] + StaffDocument["StaffDocument"] + StaffRole["StaffRole"] + Task["Task"] + TaskComment["TaskComment"] + TaxForm["TaxForm"] + Team["Team"] + TeamHub["TeamHub"] + TeamHudDepartment["TeamHudDepartment"] + TeamMember["TeamMember"] + User["User"] + UserConversation["UserConversation"] + Vendor["Vendor"] + VendorBenefitPlan["VendorBenefitPlan"] + VendorRate["VendorRate"] + Workforce["Workforce"] + + Application --> Shift + Application --> ShiftRole + Application --> Staff + Assignment --> ShiftRole + Assignment --> Workforce + BenefitsData --> Staff + BenefitsData --> VendorBenefitPlan + Certificate --> Staff + ClientFeedback --> Business + ClientFeedback --> Vendor + Course --> Category + Invoice --> Business + Invoice --> Order + Invoice --> Vendor + InvoiceTemplate --> Business + InvoiceTemplate --> Order + InvoiceTemplate --> Vendor + MemberTask --> Task + MemberTask --> TeamMember + Message --> User + Order --> Business + Order --> TeamHub + Order --> Vendor + RecentPayment --> Application + RecentPayment --> Invoice + Shift --> Order + ShiftRole --> Role + ShiftRole --> Shift + StaffAvailability --> Staff + StaffAvailabilityStats --> Staff + StaffDocument --> Document + StaffRole --> Role + StaffRole --> Staff + TaskComment --> TeamMember + TeamHub --> Team + TeamHudDepartment --> TeamHub + TeamMember --> Team + TeamMember --> TeamHub + TeamMember --> User + UserConversation --> Conversation + UserConversation --> User + VendorBenefitPlan --> Vendor + VendorRate --> Vendor + Workforce --> Staff + Workforce --> Vendor +``` From b2833b02af85e495fadc4d88f5c6be0f8338d071 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:50:13 -0500 Subject: [PATCH 14/19] docs(m4): reconcile roadmap CSVs with target schema --- CHANGELOG.md | 3 + .../m4-roadmap-csv-schema-reconciliation.md | 160 ++++++++++++++++++ .../M4/planning/m4-target-schema-blueprint.md | 96 ++++++++++- 3 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b29789f2..77bde8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,3 +21,6 @@ | 2026-02-24 | 0.1.16 | Added M4 target schema blueprint doc with first-principles modular model, constraints, and migration phases. | | 2026-02-24 | 0.1.17 | Added full current-schema mermaid model relationship map to the M4 target schema blueprint. | | 2026-02-24 | 0.1.18 | Updated schema blueprint with explicit multi-tenant stakeholder model and phased RBAC rollout with shadow mode before enforcement. | +| 2026-02-24 | 0.1.19 | Added customer stakeholder-wheel mapping and future stakeholder extension model to the M4 schema blueprint. | +| 2026-02-25 | 0.1.20 | Added roadmap CSV schema-reconciliation document with stakeholder capability matrix and concrete schema gap analysis. | +| 2026-02-25 | 0.1.21 | Updated target schema blueprint with roadmap-evidence section plus attendance/offense, stakeholder-network, and settlement-table coverage. | diff --git a/docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md b/docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md new file mode 100644 index 00000000..4b1f633b --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md @@ -0,0 +1,160 @@ +# M4 Roadmap CSV Schema Reconciliation + +Status: Draft for implementation alignment +Date: 2026-02-25 +Owner: Technical Lead + +## 1) Why this exists +We reviewed the original product-roadmap exports to confirm that the target schema can support all stakeholder lanes as the platform grows. + +This avoids two failure modes: +1. Building command APIs on top of a schema that cannot represent required workflows. +2. Hard-coding today's customer setup in a way that blocks future staffing companies. + +## 2) Inputs reviewed +All 13 roadmap exports from `/Users/wiel/Downloads`: +1. `Krow App – Roadmap - Business App_ Google, Nvidia.csv` +2. `Krow App – Roadmap - Client_ Google, Nvidia.csv` +3. `Krow App – Roadmap - Compass- The Operator.csv` +4. `Krow App – Roadmap - Employee App.csv` +5. `Krow App – Roadmap - Features.csv` +6. `Krow App – Roadmap - FoodBuy- Procurement.csv` +7. `Krow App – Roadmap - KROW Dashboard.csv` +8. `Krow App – Roadmap - Offenses.csv` +9. `Krow App – Roadmap - Partner.csv` +10. `Krow App – Roadmap - Roadmap.csv` +11. `Krow App – Roadmap - Sectors_ BA, Flik ( The executors).csv` +12. `Krow App – Roadmap - The Workforce_ Employees.csv` +13. `Krow App – Roadmap - Vendor_ Legendary (Staffing).csv` + +Parsed signal: +1. 983 non-empty task lines. +2. 1,263 planning rows with task/status/priority/reference signals. + +## 3) What the roadmap is clearly asking for +Cross-file recurring capabilities: +1. Multi-party org model: client, operator, vendor, procurement, workforce, partner, sector execution. +2. Orders and shift operations: recurring events, assignment, coverage, schedule management. +3. Attendance and policy enforcement: clock-in/out, no-show, tardiness, cancellation, offense ladders. +4. Compliance and document verification: certifications, insurance, legal docs, renewals, risk alerts. +5. Finance and settlement: invoice lifecycle, disputes, remittance, payment history, aging, payroll/earnings. +6. Reporting and prediction: dashboards, KPI, forecasting, scenario planning. + +Repeated examples across many sheets: +1. `Vendor Onboarding`, `Service Locations`, `Compliance`, `Certifications`. +2. `All Invoices (Open/Pending/Overdue/Paid...)`, `Payment Summary`, `Remittance Advice Download`. +3. Offense progression rules in `Offenses.csv` and `Employee App.csv` (warning -> suspension -> disable/block). + +## 4) Stakeholder capability matrix (from roadmap exports) + +| Stakeholder lane | Org network | Orders and shifts | Attendance and offense | Compliance docs | Finance | Reporting | +|---|---|---|---|---|---|---| +| Client (Google, Nvidia) | Yes | Yes | Partial (visibility) | Yes | Yes | Yes | +| Vendor (Legendary) | Yes | Yes | Yes | Yes | Yes | Yes | +| Workforce (Employee app) | Limited | Yes | Yes | Yes | Earnings focus | Limited | +| Operator (Compass) | Yes | Yes | KPI visibility | Yes | Yes | Yes | +| Procurement (FoodBuy) | Yes | KPI/SLA focus | KPI/SLA focus | Yes | Yes | Yes | +| KROW Dashboard | Cross-entity | Cross-entity | Cross-entity risk | Cross-entity | Cross-entity | Heavy | +| Partner | Basic | Basic | Minimal | Yes | Basic | Basic | + +Implication: +1. This is a multi-tenant, multi-party workflow platform, not a single-role CRUD app. +2. Schema must separate party identity, party relationships, and role-based permissions from workflow records. + +## 5) Reconciliation against current Data Connect schema + +What already exists and is useful: +1. Core scheduling entities: `orders`, `shifts`, `shift_roles`, `applications`, `assignments`. +2. Workforce entities: `staffs`, `workforce`, `staff_roles`. +3. Financial entities: `invoices`, `recent_payments`, `vendor_rates`. +4. Compliance entities: `documents`, `staff_documents`, `certificates`. + +Current structural gaps for roadmap scale: +1. No tenant boundary key on core tables (`tenant_id` missing). +2. No first-class stakeholder profile/link model for buyer/operator/partner/sector relationships. +3. Attendance history is not first-class (check in/out only inside `applications`). +4. No offense policy, offense event, or enforcement action tables. +5. Finance is coarse (invoice + recent payment), missing line items, payment runs, remittance artifact model. +6. Sensitive bank fields are currently modeled directly in `accounts` (`accountNumber`, `routeNumber`). +7. Many core workflow fields are JSON (`orders.assignedStaff`, `orders.shifts`, `shift.managers`, `assignment.managers`). +8. Money still uses float in critical tables. + +Connector boundary gap: +1. 147 Data Connect mutation operations exist. +2. 36 of those are high-risk core workflow mutations (`order`, `shift`, `shiftRole`, `application`, `assignment`, `invoice`, `vendor`, `business`, `workForce`, `teamMember`, `account`). + +## 6) Target schema additions required before full command rollout + +### 6.1 Tenant and stakeholder graph +1. `tenants` +2. `tenant_memberships` +3. `stakeholder_types` +4. `stakeholder_profiles` +5. `stakeholder_links` +6. `role_bindings` (scoped to tenant/team/hub/business/vendor/resource) + +### 6.2 Attendance and timesheet reliability +1. `attendance_events` (append-only clock-in/out/NFC/manual-corrected) +2. `attendance_sessions` (derived per shift role assignment) +3. `timesheets` (approval state and pay-eligible snapshot) +4. `timesheet_adjustments` (manual corrections with audit reason) + +### 6.3 Offense and policy enforcement +1. `offense_policies` (per tenant or per business) +2. `offense_rules` (threshold and consequence ladder) +3. `offense_events` (who, what, when, evidence) +4. `enforcement_actions` (warning, suspension, disable, block-from-client) + +### 6.4 Compliance and verification +1. `verification_jobs` +2. `verification_reviews` +3. `verification_events` +4. `compliance_requirements` (per role, tenant, business, or client contract) + +### 6.5 Finance completeness +1. `invoice_line_items` +2. `invoice_status_history` +3. `payment_runs` +4. `payment_allocations` +5. `remittance_documents` +6. `account_token_refs` (tokenized provider refs, no raw account/routing) + +## 7) Minimal target relationship view + +```mermaid +erDiagram + TENANT ||--o{ TENANT_MEMBERSHIP : has + TENANT ||--o{ STAKEHOLDER_PROFILE : has + STAKEHOLDER_PROFILE ||--o{ STAKEHOLDER_LINK : links_to + + TENANT ||--o{ BUSINESS : owns + TENANT ||--o{ VENDOR : owns + BUSINESS ||--o{ ORDER : requests + VENDOR ||--o{ ORDER : fulfills + ORDER ||--o{ SHIFT : expands_to + SHIFT ||--o{ SHIFT_ROLE : requires + SHIFT_ROLE ||--o{ APPLICATION : receives + APPLICATION ||--o{ ASSIGNMENT : becomes + + ASSIGNMENT ||--o{ ATTENDANCE_EVENT : emits + ASSIGNMENT ||--o{ TIMESHEET : settles + OFFENSE_POLICY ||--o{ OFFENSE_RULE : defines + ASSIGNMENT ||--o{ OFFENSE_EVENT : may_trigger + OFFENSE_EVENT ||--o{ ENFORCEMENT_ACTION : causes + + ORDER ||--o{ INVOICE : bills + INVOICE ||--o{ INVOICE_LINE_ITEM : contains + PAYMENT_RUN ||--o{ PAYMENT_ALLOCATION : allocates + INVOICE ||--o{ PAYMENT_ALLOCATION : receives + PAYMENT_RUN ||--o{ REMITTANCE_DOCUMENT : publishes +``` + +## 8) First-principles rules we should lock now +1. Every command-critical table includes `tenant_id`. +2. High-risk writes go through command APIs only. +3. Money uses exact numeric type (or integer cents), never float. +4. Core workflow state is relational and constrained, not JSON blobs. +5. Every irreversible state change has append-only audit event rows. + +## 9) Decision +This roadmap evidence supports continuing with the target architecture direction (command boundary + multi-tenant schema), but we should add attendance/offense/settlement/stakeholder-graph tables before full command rollout on mission-critical flows. diff --git a/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md b/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md index 5a709ad9..7c77aac3 100644 --- a/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md +++ b/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md @@ -1,7 +1,7 @@ # M4 Target Schema Blueprint (Command-Ready) Status: Draft for team alignment -Date: 2026-02-24 +Date: 2026-02-25 Owner: Technical Lead ## 1) Goal @@ -36,6 +36,49 @@ flowchart LR V --> O ``` +## 1.2 Stakeholder wheel mapping (current baseline) +The stakeholder labels from the customer workshop map to schema as follows: + +1. Buyer (Procurements): +- Buyer users inside a business/client account. +- Schema anchor: `users` + `tenant_memberships` + `team_members` (procurement team scope). +2. Enterprises (Operator): +- Tenant operator/admin users running staffing operations. +- Schema anchor: `tenants`, `team_members`, command-side permissions. +3. Sectors (Execution): +- Operational segments or business units executing events. +- Schema anchor: `teams`, `team_hubs`, `team_hud_departments`, `roles`. +4. Approved Vendor: +- Supplier companies approved to fulfill staffing demand. +- Schema anchor: `vendors`, `workforce`, `vendor_rates`, `vendor_benefit_plans`. +5. Workforce: +- Individual workers/staff and their assignments. +- Schema anchor: `staffs`, `staff_roles`, `applications`, `assignments`, `certificates`, `staff_documents`. +6. Partner: +- External integration or service partner (future). +- Schema anchor: `stakeholder_profiles` extension path + scoped role bindings. + +Rule: +1. Start with baseline stakeholders above. +2. Add new stakeholders via extension tables and role bindings, not by changing core scheduling and finance tables. + +## 1.3 Future stakeholder expansion model +To add stakeholders later without breaking core schema: +1. Add `stakeholder_types` (registry). +2. Add `stakeholder_profiles` (`tenant_id`, `type`, `status`, `metadata`). +3. Add `stakeholder_links` (relationship graph across stakeholders). +4. Bind permissions through `role_bindings` with scope (`tenant`, `team`, `hub`, `business`, or specific resource). + +## 1.4 Roadmap CSV evidence snapshot +Evidence source: +1. `docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md` + +What the exports confirmed: +1. The product is multi-party and multi-tenant by design (client, operator, vendor, workforce, procurement, partner, dashboard). +2. Attendance and offense enforcement are core business workflows, not side features. +3. Finance requires more than invoices (payment runs, remittance, status history, dispute/audit trace). +4. Compliance requires asynchronous verification and requirement templates by tenant/business/role. + ## 2) First-principles rules 1. Every critical write must be server-mediated and transactional. 2. Tenant boundaries must be explicit in data and queries. @@ -171,6 +214,33 @@ Rules: 1. Every command write emits a domain event. 2. Idempotency scope: `(actor_uid, route, idempotency_key)`. +## 4.9 Attendance, Timesheets, and Offense Governance +Tables: +1. `attendance_events` (append-only: clock-in/out, source, correction metadata) +2. `attendance_sessions` (derived work session per assignment) +3. `timesheets` (approval-ready payroll snapshot) +4. `timesheet_adjustments` (manual edits with reason and actor) +5. `offense_policies` (tenant/business scoped policy set) +6. `offense_rules` (threshold ladder and consequence) +7. `offense_events` (actual violation events) +8. `enforcement_actions` (warning, suspension, disable, block) + +Rules: +1. Attendance corrections are additive events, not destructive overwrites. +2. Offense consequences are computed from policy + history and persisted as explicit actions. +3. Manual overrides require actor, reason, and timestamp in audit trail. + +## 4.10 Stakeholder Network Extensibility +Tables: +1. `stakeholder_types` (buyer, operator, vendor, workforce, partner, future types) +2. `stakeholder_profiles` (tenant-scoped typed profile) +3. `stakeholder_links` (explicit relationship graph between profiles) + +Rules: +1. New stakeholder categories are added by data, not by schema rewrites to core workflow tables. +2. Permission scope resolves through role bindings plus stakeholder links where needed. +3. Scheduling and finance records remain stable while stakeholder topology evolves. + ## 5) Target core model (conceptual) ```mermaid @@ -196,6 +266,11 @@ erDiagram STAFF ||--o{ APPLICATION : applies SHIFT_ROLE ||--o{ ASSIGNMENT : allocates WORKFORCE ||--o{ ASSIGNMENT : executes + ASSIGNMENT ||--o{ ATTENDANCE_EVENT : emits + ASSIGNMENT ||--o{ TIMESHEET : settles + OFFENSE_POLICY ||--o{ OFFENSE_RULE : defines + ASSIGNMENT ||--o{ OFFENSE_EVENT : may_trigger + OFFENSE_EVENT ||--o{ ENFORCEMENT_ACTION : causes STAFF ||--o{ CERTIFICATE : has STAFF ||--o{ STAFF_DOCUMENT : uploads @@ -207,10 +282,16 @@ erDiagram ORDER ||--o{ INVOICE : billed_by INVOICE ||--o{ RECENT_PAYMENT : settles TENANT ||--o{ ACCOUNT_TOKEN_REF : payout_method + INVOICE ||--o{ INVOICE_LINE_ITEM : details + PAYMENT_RUN ||--o{ PAYMENT_ALLOCATION : allocates + INVOICE ||--o{ PAYMENT_ALLOCATION : receives + PAYMENT_RUN ||--o{ REMITTANCE_DOCUMENT : publishes ORDER ||--o{ DOMAIN_EVENT : emits SHIFT ||--o{ DOMAIN_EVENT : emits ASSIGNMENT ||--o{ DOMAIN_EVENT : emits + STAKEHOLDER_TYPE ||--o{ STAKEHOLDER_PROFILE : classifies + STAKEHOLDER_PROFILE ||--o{ STAKEHOLDER_LINK : relates ``` ## 6) Command write boundary on this schema @@ -234,6 +315,8 @@ flowchart LR 4. `workforce`: unique active `(vendor_id, staff_id)`. 5. `team_members`: unique `(team_id, user_id)`. 6. `accounts` (or token ref table): unique primary per owner. +7. `attendance_events`: unique idempotency tuple (for example `(assignment_id, source_event_id)`). +8. `offense_rules`: unique `(policy_id, trigger_type, threshold_count)`. ## 7.2 Indexes 1. `orders (tenant_id, status, date)`. @@ -243,6 +326,9 @@ flowchart LR 5. `assignments (workforce_id, shift_id, role_id, status)`. 6. `verification_jobs (subject_id, type, status, created_at)`. 7. `invoices (business_id, vendor_id, status, due_date)`. +8. `attendance_events (assignment_id, event_time, event_type)`. +9. `offense_events (staff_id, occurred_at, offense_type, status)`. +10. `invoice_line_items (invoice_id, line_type, created_at)`. ## 8) Data type normalization 1. Monetary: `Float -> DECIMAL(12,2)` (or integer cents). @@ -271,11 +357,13 @@ flowchart TD - run RBAC in shadow mode"] P2 --> P3["Phase 3: Scheduling normalization - remove order JSON workflow fields - - add order_schedule_rules and shift_managers"] + - add order_schedule_rules and shift_managers + - add attendance and offense base tables"] P3 --> P4["Phase 4: Command rollout - command writes on hardened schema - emit domain events + idempotency - - enforce RBAC for command routes"] + - enforce RBAC for command routes + - add finance settlement tables for payment runs and remittance"] P4 --> P5["Phase 5: Read migration + cleanup - migrate frontend reads as needed - enforce RBAC for sensitive reads @@ -289,6 +377,8 @@ flowchart TD 4. Core transaction invariants enforced by schema constraints. 5. Command API contracts mapped to new normalized tables. 6. RBAC is in shadow mode with decision logs in place (not hard-blocking yet). +7. Attendance and offense tables are ready for policy-driven command routes. +8. Finance settlement tables (`invoice_line_items`, `payment_runs`, `payment_allocations`) are available. ## 12) Full current model relationship map (all models) From 3dc3b306ea3c9afa6d10c1efe37291f01d224bd0 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:58:25 -0500 Subject: [PATCH 15/19] docs(m4): add core data actors with two example scenarios --- .../planning/m4-core-data-actors-scenarios.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md diff --git a/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md b/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md new file mode 100644 index 00000000..89264b62 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md @@ -0,0 +1,82 @@ +# M4 Core Data Actors and Example Scenarios + +Status: Working draft +Date: 2026-02-25 +Owner: Technical Lead + +## 1) Core data actors +1. `Tenant`: staffing company boundary and data isolation root. +2. `User`: human identity that signs in. +3. `TenantMembership`: user role/context inside one tenant. +4. `Business`: client account served by the tenant. +5. `Vendor`: supplier account that can fulfill staffing demand. +6. `Workforce/Staff`: worker profile used for assignment and attendance. +7. `StakeholderType`: typed category (`buyer`, `operator`, `vendor_partner`, `workforce`, `partner`). +8. `StakeholderProfile`: typed actor record inside a tenant. +9. `StakeholderLink`: relationship between stakeholder profiles. + +## 2) Minimal actor map + +```mermaid +flowchart LR + T["Tenant"] --> TM["TenantMembership"] + U["User"] --> TM + T --> B["Business"] + T --> V["Vendor"] + U --> S["Workforce/Staff"] + T --> SP["StakeholderProfile"] + ST["StakeholderType"] --> SP + SP --> SL["StakeholderLink"] + B --> O["Orders/Shifts"] + V --> O + S --> O +``` + +## 3) Scenario A: Legendary Event Staffing +Context: +1. Tenant is `Legendary Event Staffing and Entertainment`. +2. Business is `Google Mountain View Cafes`. +3. Legendary uses its own workforce, and can still route overflow to approved vendors. + +```mermaid +sequenceDiagram + participant Tenant as "Tenant (Legendary)" + participant Biz as "Business (Google Cafes)" + participant Ops as "User + TenantMembership (Ops Lead)" + participant Staff as "Workforce/Staff (Barista Ana)" + participant Link as "StakeholderLink" + + Biz->>Ops: "Create staffing request" + Ops->>Tenant: "Create order under tenant scope" + Ops->>Link: "Resolve business-to-vendor/workforce relationships" + Ops->>Staff: "Assign qualified worker" + Staff-->>Ops: "Clock in/out and complete shift" + Ops-->>Biz: "Invoice/report generated with audit trail" +``` + +## 4) Scenario B: Another tenant (external vendor-heavy) +Context: +1. Tenant is `Peakline Events`. +2. Business is `NVIDIA Campus Dining`. +3. Peakline primarily fulfills demand through external approved vendors. + +```mermaid +sequenceDiagram + participant Tenant as "Tenant (Peakline Events)" + participant Biz as "Business (NVIDIA Dining)" + participant Ops as "User + TenantMembership (Coordinator)" + participant Vendor as "Vendor (Metro Staffing)" + participant Staff as "Workforce/Staff (Vendor worker)" + + Biz->>Ops: "Request recurring event staffing" + Ops->>Tenant: "Create recurring order" + Ops->>Vendor: "Dispatch required roles" + Vendor->>Staff: "Provide available workers" + Staff-->>Ops: "Attendance and completion events" + Ops-->>Biz: "Settlement + performance reports" +``` + +## 5) Why this model scales +1. New stakeholders can be added through `StakeholderType` and `StakeholderProfile` without changing core order/shift tables. +2. Multi-tenant isolation stays strict because all critical records resolve through `Tenant`. +3. Legendary and non-Legendary operating models both fit the same structure. From 50c7116d20ba3c309ad422a18e418fb3918560af Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:00:12 -0500 Subject: [PATCH 16/19] docs(m4): fix mermaid participant alias for github render --- docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md b/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md index 89264b62..f6dd20a2 100644 --- a/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md +++ b/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md @@ -44,11 +44,11 @@ sequenceDiagram participant Biz as "Business (Google Cafes)" participant Ops as "User + TenantMembership (Ops Lead)" participant Staff as "Workforce/Staff (Barista Ana)" - participant Link as "StakeholderLink" + participant StakeRel as "StakeholderLink" Biz->>Ops: "Create staffing request" Ops->>Tenant: "Create order under tenant scope" - Ops->>Link: "Resolve business-to-vendor/workforce relationships" + Ops->>StakeRel: "Resolve business-to-vendor/workforce relationships" Ops->>Staff: "Assign qualified worker" Staff-->>Ops: "Clock in/out and complete shift" Ops-->>Biz: "Invoice/report generated with audit trail" From 67cf5e0e4ca0389d033a7ed9ed78bc3f5bebc300 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:17:54 -0500 Subject: [PATCH 17/19] docs(m4): add explicit actor mapping text to both scenarios --- .../planning/m4-core-data-actors-scenarios.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md b/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md index f6dd20a2..7826edbe 100644 --- a/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md +++ b/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md @@ -38,6 +38,26 @@ Context: 2. Business is `Google Mountain View Cafes`. 3. Legendary uses its own workforce, and can still route overflow to approved vendors. +Actor mapping (text): +1. Tenant: `Legendary Event Staffing and Entertainment` (the company using Krow). +2. User: `Wil` (ops lead), `Maria` (client manager), `Ana` (worker). +3. TenantMembership: +4. `Wil` is `admin` in Legendary tenant. +5. `Maria` is `manager` in Legendary tenant. +6. `Ana` is `member` in Legendary tenant. +7. Business: `Google Mountain View Cafes` (client account under the tenant). +8. Vendor: `Legendary Staffing Pool A` (or an external approved vendor). +9. Workforce/Staff: `Ana` is a staff profile in workforce, linked to certifications and assignments. +10. StakeholderType: `buyer`, `operator`, `vendor_partner`, `workforce`, `procurement_partner`. +11. StakeholderProfile: +12. `Google Procurement` = `buyer`. +13. `Legendary Ops` = `operator`. +14. `FoodBuy` = `procurement_partner`. +15. StakeholderLink: +16. `Google Procurement` `contracts_with` `Legendary Ops`. +17. `Legendary Ops` `sources_from` `Vendor`. +18. `Google Procurement` `reports_through` `FoodBuy`. + ```mermaid sequenceDiagram participant Tenant as "Tenant (Legendary)" @@ -60,6 +80,26 @@ Context: 2. Business is `NVIDIA Campus Dining`. 3. Peakline primarily fulfills demand through external approved vendors. +Actor mapping (text): +1. Tenant: `Peakline Events` (another staffing company using Krow). +2. User: `Chris` (operations coordinator), `Nina` (client manager), `Leo` (worker). +3. TenantMembership: +4. `Chris` is `admin` in Peakline tenant. +5. `Nina` is `manager` in Peakline tenant. +6. `Leo` is `member` in Peakline tenant. +7. Business: `NVIDIA Campus Dining` (client account under the tenant). +8. Vendor: `Metro Staffing LLC` (approved external vendor for this tenant). +9. Workforce/Staff: `Leo` is a workforce profile fulfilled through Metro Staffing for assignment and attendance. +10. StakeholderType: `buyer`, `operator`, `vendor_partner`, `workforce`, `procurement_partner`. +11. StakeholderProfile: +12. `NVIDIA Procurement` = `buyer`. +13. `Peakline Ops` = `operator`. +14. `FoodBuy Regional` = `procurement_partner`. +15. StakeholderLink: +16. `NVIDIA Procurement` `contracts_with` `Peakline Ops`. +17. `Peakline Ops` `sources_from` `Metro Staffing LLC`. +18. `NVIDIA Procurement` `reports_through` `FoodBuy Regional`. + ```mermaid sequenceDiagram participant Tenant as "Tenant (Peakline Events)" From b645927429af938e5f32edb4cdd68d6981c2797a Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:58:21 -0500 Subject: [PATCH 18/19] docs(m4): add business/vendor memberships and clean planning docs --- CHANGELOG.md | 3 + .../planning/m4-core-data-actors-scenarios.md | 144 ++++++++++++------ .../M4/planning/m4-discrepencies.md | 47 ------ .../m4-roadmap-csv-schema-reconciliation.md | 30 ++-- .../M4/planning/m4-target-schema-blueprint.md | 28 ++-- 5 files changed, 135 insertions(+), 117 deletions(-) delete mode 100644 docs/MILESTONES/M4/planning/m4-discrepencies.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 77bde8c2..dd7db650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,3 +24,6 @@ | 2026-02-24 | 0.1.19 | Added customer stakeholder-wheel mapping and future stakeholder extension model to the M4 schema blueprint. | | 2026-02-25 | 0.1.20 | Added roadmap CSV schema-reconciliation document with stakeholder capability matrix and concrete schema gap analysis. | | 2026-02-25 | 0.1.21 | Updated target schema blueprint with roadmap-evidence section plus attendance/offense, stakeholder-network, and settlement-table coverage. | +| 2026-02-25 | 0.1.22 | Updated core actor scenarios with explicit business and vendor user partitioning via membership tables. | +| 2026-02-25 | 0.1.23 | Updated schema blueprint and reconciliation docs to add `business_memberships` and `vendor_memberships` as first-class data actors. | +| 2026-02-25 | 0.1.24 | Removed stale `m4-discrepencies.md` document from M4 planning docs cleanup. | diff --git a/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md b/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md index 7826edbe..8c53fb69 100644 --- a/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md +++ b/docs/MILESTONES/M4/planning/m4-core-data-actors-scenarios.md @@ -9,20 +9,38 @@ Owner: Technical Lead 2. `User`: human identity that signs in. 3. `TenantMembership`: user role/context inside one tenant. 4. `Business`: client account served by the tenant. -5. `Vendor`: supplier account that can fulfill staffing demand. -6. `Workforce/Staff`: worker profile used for assignment and attendance. -7. `StakeholderType`: typed category (`buyer`, `operator`, `vendor_partner`, `workforce`, `partner`). -8. `StakeholderProfile`: typed actor record inside a tenant. -9. `StakeholderLink`: relationship between stakeholder profiles. +5. `BusinessMembership`: maps users to a business with role/status (`owner`, `manager`, `approver`, `viewer`). +6. `Vendor`: supplier account that can fulfill staffing demand. +7. `VendorMembership`: maps users to a vendor with role/status (`owner`, `manager`, `scheduler`, `viewer`). +8. `Workforce/Staff`: worker profile used for assignment and attendance. +9. `StakeholderType`: typed category (`buyer`, `operator`, `vendor_partner`, `workforce`, `partner`, `procurement_partner`). +10. `StakeholderProfile`: typed actor record inside a tenant. +11. `StakeholderLink`: relationship between stakeholder profiles. + +## 1.1 Current schema coverage (today) +Current Data Connect handles this only partially: +1. `Business.userId` supports one primary business user. +2. `Vendor.userId` supports one primary vendor user. +3. `TeamMember` can represent multiple users by team as a workaround. + +This is why we need first-class membership tables: +1. `business_memberships` +2. `vendor_memberships` + +Without those, client/vendor user partitioning is indirect and harder to enforce safely at scale. ## 2) Minimal actor map ```mermaid flowchart LR - T["Tenant"] --> TM["TenantMembership"] + T["Tenant"] --> TM["TenantMembership (global tenant access)"] U["User"] --> TM T --> B["Business"] T --> V["Vendor"] + U --> BM["BusinessMembership"] + BM --> B + U --> VM["VendorMembership"] + VM --> V U --> S["Workforce/Staff"] T --> SP["StakeholderProfile"] ST["StakeholderType"] --> SP @@ -40,38 +58,48 @@ Context: Actor mapping (text): 1. Tenant: `Legendary Event Staffing and Entertainment` (the company using Krow). -2. User: `Wil` (ops lead), `Maria` (client manager), `Ana` (worker). +2. User: `Wil` (ops lead), `Maria` (Google client manager), `Omar` (Google procurement approver), `Jose` (vendor scheduler), `Ana` (worker). 3. TenantMembership: 4. `Wil` is `admin` in Legendary tenant. -5. `Maria` is `manager` in Legendary tenant. -6. `Ana` is `member` in Legendary tenant. -7. Business: `Google Mountain View Cafes` (client account under the tenant). -8. Vendor: `Legendary Staffing Pool A` (or an external approved vendor). -9. Workforce/Staff: `Ana` is a staff profile in workforce, linked to certifications and assignments. -10. StakeholderType: `buyer`, `operator`, `vendor_partner`, `workforce`, `procurement_partner`. -11. StakeholderProfile: -12. `Google Procurement` = `buyer`. -13. `Legendary Ops` = `operator`. -14. `FoodBuy` = `procurement_partner`. -15. StakeholderLink: -16. `Google Procurement` `contracts_with` `Legendary Ops`. -17. `Legendary Ops` `sources_from` `Vendor`. -18. `Google Procurement` `reports_through` `FoodBuy`. +5. `Maria` is `member` in Legendary tenant. +6. `Omar` is `member` in Legendary tenant. +7. `Jose` is `member` in Legendary tenant. +8. `Ana` is `member` in Legendary tenant. +9. BusinessMembership: +10. `Maria` is `manager` in `Google Mountain View Cafes`. +11. `Omar` is `approver` in `Google Mountain View Cafes`. +12. VendorMembership: +13. `Jose` is `scheduler` in `Legendary Staffing Pool A`. +14. `Wil` is `owner` in `Legendary Staffing Pool A`. +15. Business: `Google Mountain View Cafes` (client account under the tenant). +16. Vendor: `Legendary Staffing Pool A` (or an external approved vendor). +17. Workforce/Staff: `Ana` is a staff profile in workforce, linked to certifications and assignments. +18. StakeholderType: `buyer`, `operator`, `vendor_partner`, `workforce`, `procurement_partner`. +19. StakeholderProfile: +20. `Google Procurement` = `buyer`. +21. `Legendary Ops` = `operator`. +22. `FoodBuy` = `procurement_partner`. +23. StakeholderLink: +24. `Google Procurement` `contracts_with` `Legendary Ops`. +25. `Legendary Ops` `sources_from` `Legendary Staffing Pool A`. +26. `Google Procurement` `reports_through` `FoodBuy`. ```mermaid sequenceDiagram participant Tenant as "Tenant (Legendary)" - participant Biz as "Business (Google Cafes)" - participant Ops as "User + TenantMembership (Ops Lead)" + participant BizUser as "Business user (Maria/Omar)" + participant Ops as "Ops user (Wil)" + participant VendorUser as "Vendor user (Jose)" participant Staff as "Workforce/Staff (Barista Ana)" participant StakeRel as "StakeholderLink" - Biz->>Ops: "Create staffing request" + BizUser->>Ops: "Create staffing request" Ops->>Tenant: "Create order under tenant scope" Ops->>StakeRel: "Resolve business-to-vendor/workforce relationships" - Ops->>Staff: "Assign qualified worker" + Ops->>VendorUser: "Dispatch role demand" + VendorUser->>Staff: "Confirm worker assignment" Staff-->>Ops: "Clock in/out and complete shift" - Ops-->>Biz: "Invoice/report generated with audit trail" + Ops-->>BizUser: "Invoice/report generated with audit trail" ``` ## 4) Scenario B: Another tenant (external vendor-heavy) @@ -82,41 +110,57 @@ Context: Actor mapping (text): 1. Tenant: `Peakline Events` (another staffing company using Krow). -2. User: `Chris` (operations coordinator), `Nina` (client manager), `Leo` (worker). +2. User: `Chris` (operations coordinator), `Nina` (client manager), `Sam` (vendor manager), `Leo` (worker). 3. TenantMembership: 4. `Chris` is `admin` in Peakline tenant. -5. `Nina` is `manager` in Peakline tenant. -6. `Leo` is `member` in Peakline tenant. -7. Business: `NVIDIA Campus Dining` (client account under the tenant). -8. Vendor: `Metro Staffing LLC` (approved external vendor for this tenant). -9. Workforce/Staff: `Leo` is a workforce profile fulfilled through Metro Staffing for assignment and attendance. -10. StakeholderType: `buyer`, `operator`, `vendor_partner`, `workforce`, `procurement_partner`. -11. StakeholderProfile: -12. `NVIDIA Procurement` = `buyer`. -13. `Peakline Ops` = `operator`. -14. `FoodBuy Regional` = `procurement_partner`. -15. StakeholderLink: -16. `NVIDIA Procurement` `contracts_with` `Peakline Ops`. -17. `Peakline Ops` `sources_from` `Metro Staffing LLC`. -18. `NVIDIA Procurement` `reports_through` `FoodBuy Regional`. +5. `Nina` is `member` in Peakline tenant. +6. `Sam` is `member` in Peakline tenant. +7. `Leo` is `member` in Peakline tenant. +8. BusinessMembership: +9. `Nina` is `manager` in `NVIDIA Campus Dining`. +10. VendorMembership: +11. `Sam` is `manager` in `Metro Staffing LLC`. +12. Business: `NVIDIA Campus Dining` (client account under the tenant). +13. Vendor: `Metro Staffing LLC` (approved external vendor for this tenant). +14. Workforce/Staff: `Leo` is a workforce profile fulfilled through Metro Staffing for assignment and attendance. +15. StakeholderType: `buyer`, `operator`, `vendor_partner`, `workforce`, `procurement_partner`. +16. StakeholderProfile: +17. `NVIDIA Procurement` = `buyer`. +18. `Peakline Ops` = `operator`. +19. `FoodBuy Regional` = `procurement_partner`. +20. StakeholderLink: +21. `NVIDIA Procurement` `contracts_with` `Peakline Ops`. +22. `Peakline Ops` `sources_from` `Metro Staffing LLC`. +23. `NVIDIA Procurement` `reports_through` `FoodBuy Regional`. ```mermaid sequenceDiagram participant Tenant as "Tenant (Peakline Events)" - participant Biz as "Business (NVIDIA Dining)" - participant Ops as "User + TenantMembership (Coordinator)" - participant Vendor as "Vendor (Metro Staffing)" + participant BizUser as "Business user (Nina)" + participant Ops as "Ops user (Chris)" + participant VendorUser as "Vendor user (Sam)" participant Staff as "Workforce/Staff (Vendor worker)" - Biz->>Ops: "Request recurring event staffing" + BizUser->>Ops: "Request recurring event staffing" Ops->>Tenant: "Create recurring order" - Ops->>Vendor: "Dispatch required roles" - Vendor->>Staff: "Provide available workers" + Ops->>VendorUser: "Dispatch required roles" + VendorUser->>Staff: "Provide available workers" Staff-->>Ops: "Attendance and completion events" - Ops-->>Biz: "Settlement + performance reports" + Ops-->>BizUser: "Settlement + performance reports" ``` ## 5) Why this model scales 1. New stakeholders can be added through `StakeholderType` and `StakeholderProfile` without changing core order/shift tables. -2. Multi-tenant isolation stays strict because all critical records resolve through `Tenant`. -3. Legendary and non-Legendary operating models both fit the same structure. +2. Business and vendor user partitioning is explicit through membership tables, not hidden in one owner field. +3. Multi-tenant isolation stays strict because all critical records resolve through `Tenant`. +4. Legendary and non-Legendary operating models both fit the same structure. + +## 6) Industry alignment (primary references) +1. Google Cloud Identity Platform multi-tenancy: +- https://docs.cloud.google.com/identity-platform/docs/multi-tenancy +2. AWS SaaS tenant isolation fundamentals: +- https://docs.aws.amazon.com/whitepapers/latest/saas-architecture-fundamentals/tenant-isolation.html +3. B2B organization-aware login flows (Auth0 Organizations): +- https://auth0.com/docs/manage-users/organizations/login-flows-for-organizations +4. Supplier-side multi-user management patterns (Coupa Supplier Portal): +- https://compass.coupa.com/en-us/products/product-documentation/supplier-resources/for-suppliers/coupa-supplier-portal/set-up-the-csp/users/manage-users diff --git a/docs/MILESTONES/M4/planning/m4-discrepencies.md b/docs/MILESTONES/M4/planning/m4-discrepencies.md deleted file mode 100644 index 86f8f6f6..00000000 --- a/docs/MILESTONES/M4/planning/m4-discrepencies.md +++ /dev/null @@ -1,47 +0,0 @@ -# M4 Planning Phase: Identified Discrepancies and Enhancements - -## Feedback and Discrepancies (Based on M3 Review done by Iliana) - -### Mobile Application (Client & Staff) -- **Flexible Shift Locations:** Feedback from the M3 review indicated a need for the ability to specify different locations for individual positions within an order on the mobile app. Currently, the web application handles this by requiring a separate shift for each location. - -- **Order Visibility Management:** Currently, when an order is created with multiple positions, the "View Order" page displays them as separate orders. This is due to UI (prototype) cannot support multi-position views in the order. We should consider adopting the "legacy" app's approach to group these positions under a single order for better clarity. - -- **Cost Center Clarification:** The purpose and functionality of the "Cost Center" field in the Hub creation process is not clear. - -- **Tax Form Data Completeness:** Feedback noted that while tax forms are visible in the Staff mobile application, they appear to be missing critical information. This not clear. - -### Web Dashboard -- **Role-Based Content Logic:** The current web dashboard prototype contains some logical inconsistencies regarding user roles: - - **Client Dashboard:** Currently includes Staff Availability, Staff Directory, and Staff Onboarding. Since workers (Staff) are managed by Vendors, these pages should be moved to the Vendor dashboard. - - - **Vendor Dashboard:** Currently includes "Teams and Hubs." Since Hubs are client-specific locations where staff clock in/out, these management pages should be moved to the Client dashboard. - - - **Admin Dashboard Filtering:** The Admin dashboard requires improved filtering capabilities. Admins should be able to select specific Clients or Vendors to filter related data, such as viewing only the orders associated with a chosen partner. - -## Proposed Features and Enhancements (Post-M3 Identification) - -- **Feature: Navigation to Hub Details from Coverage Screen (#321)** - - **Description:** Allow users to navigate directly to the Hub Details page by clicking on a hub within the Coverage Screen. - -- **Feature: Dedicated Hub Details Screen with Order History (#320)** - - **Description:** Develop a comprehensive Hub Details view that aggregates all hub-specific data, including historical order records. - - **Benefit:** Centralizes information for better decision-making and easier access to historical data. - -- **Feature: Dedicated Order Details Screen** - - **Description:** Transition from displaying all order information on the primary "View Order" page to a dedicated "Order Details" screen. This screen will support viewing multiple positions within a single order. - - **Benefit:** - - **Improved UX:** Reduces complexity by grouping associated positions together and presenting them in a structured way. - - **Performance:** Optimizes data loading by fetching detailed position information only when requested. - -- **Feature: Optimized Clock-In Page (#350)** - - **Description:** Remove the calendar component from the Clock-In page. Since workers only clock in for current-day assignments, the calendar is unnecessary. - - **Benefit:** Simplifies the interface and reduces user confusion. - -- **Feature: Contextual Shift Actions** - - **Description:** Restrict the Clock-In page to show only active or upcoming shifts (starting within 30 minutes). Shift-specific actions (Clock-In/Clock-Out) should be performed within the specific Shift Details page. - - **Reasoning:** This solves issues where staff cannot clock out of overnight shifts (shifts starting one day and ending the next) due to the current day-based UI. - -- **Feature: Dedicated Emergency Contact Management (#356)** - - **Description:** Replace the inline form in the "View Emergency Contact" page with a dedicated "Create Emergency Contact" screen. - - **Benefit:** Standardizes the data entry process and improves UI organization within the Staff app. diff --git a/docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md b/docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md index 4b1f633b..bcd8e7fc 100644 --- a/docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md +++ b/docs/MILESTONES/M4/planning/m4-roadmap-csv-schema-reconciliation.md @@ -71,13 +71,15 @@ What already exists and is useful: Current structural gaps for roadmap scale: 1. No tenant boundary key on core tables (`tenant_id` missing). -2. No first-class stakeholder profile/link model for buyer/operator/partner/sector relationships. -3. Attendance history is not first-class (check in/out only inside `applications`). -4. No offense policy, offense event, or enforcement action tables. -5. Finance is coarse (invoice + recent payment), missing line items, payment runs, remittance artifact model. -6. Sensitive bank fields are currently modeled directly in `accounts` (`accountNumber`, `routeNumber`). -7. Many core workflow fields are JSON (`orders.assignedStaff`, `orders.shifts`, `shift.managers`, `assignment.managers`). -8. Money still uses float in critical tables. +2. No first-class business user partitioning table (`business_memberships` missing). +3. No first-class vendor user partitioning table (`vendor_memberships` missing). +4. No first-class stakeholder profile/link model for buyer/operator/partner/sector relationships. +5. Attendance history is not first-class (check in/out only inside `applications`). +6. No offense policy, offense event, or enforcement action tables. +7. Finance is coarse (invoice + recent payment), missing line items, payment runs, remittance artifact model. +8. Sensitive bank fields are currently modeled directly in `accounts` (`accountNumber`, `routeNumber`). +9. Many core workflow fields are JSON (`orders.assignedStaff`, `orders.shifts`, `shift.managers`, `assignment.managers`). +10. Money still uses float in critical tables. Connector boundary gap: 1. 147 Data Connect mutation operations exist. @@ -88,10 +90,12 @@ Connector boundary gap: ### 6.1 Tenant and stakeholder graph 1. `tenants` 2. `tenant_memberships` -3. `stakeholder_types` -4. `stakeholder_profiles` -5. `stakeholder_links` -6. `role_bindings` (scoped to tenant/team/hub/business/vendor/resource) +3. `business_memberships` +4. `vendor_memberships` +5. `stakeholder_types` +6. `stakeholder_profiles` +7. `stakeholder_links` +8. `role_bindings` (scoped to tenant/team/hub/business/vendor/resource) ### 6.2 Attendance and timesheet reliability 1. `attendance_events` (append-only clock-in/out/NFC/manual-corrected) @@ -124,6 +128,10 @@ Connector boundary gap: ```mermaid erDiagram TENANT ||--o{ TENANT_MEMBERSHIP : has + BUSINESS ||--o{ BUSINESS_MEMBERSHIP : has + VENDOR ||--o{ VENDOR_MEMBERSHIP : has + USER ||--o{ BUSINESS_MEMBERSHIP : belongs_to + USER ||--o{ VENDOR_MEMBERSHIP : belongs_to TENANT ||--o{ STAKEHOLDER_PROFILE : has STAKEHOLDER_PROFILE ||--o{ STAKEHOLDER_LINK : links_to diff --git a/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md b/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md index 7c77aac3..2f81014b 100644 --- a/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md +++ b/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md @@ -25,6 +25,7 @@ Practical meaning: 3. Not every record starts as a full active user: - invite-first or pending onboarding records are valid, - then bound to `user_id` when activation is completed. +4. Business-side users and vendor-side users are partitioned with dedicated membership tables, not only one `userId` owner field. ```mermaid flowchart LR @@ -41,16 +42,16 @@ The stakeholder labels from the customer workshop map to schema as follows: 1. Buyer (Procurements): - Buyer users inside a business/client account. -- Schema anchor: `users` + `tenant_memberships` + `team_members` (procurement team scope). +- Schema anchor: `users` + `tenant_memberships` + `business_memberships`. 2. Enterprises (Operator): - Tenant operator/admin users running staffing operations. -- Schema anchor: `tenants`, `team_members`, command-side permissions. +- Schema anchor: `tenants`, `tenant_memberships`, `role_bindings`. 3. Sectors (Execution): - Operational segments or business units executing events. - Schema anchor: `teams`, `team_hubs`, `team_hud_departments`, `roles`. 4. Approved Vendor: - Supplier companies approved to fulfill staffing demand. -- Schema anchor: `vendors`, `workforce`, `vendor_rates`, `vendor_benefit_plans`. +- Schema anchor: `vendors`, `vendor_memberships`, `workforce`, `vendor_rates`, `vendor_benefit_plans`. 5. Workforce: - Individual workers/staff and their assignments. - Schema anchor: `staffs`, `staff_roles`, `applications`, `assignments`, `certificates`, `staff_documents`. @@ -99,15 +100,19 @@ What the exports confirmed: Tables: 1. `users` (source identity, profile, auth linkage) 2. `tenant_memberships` (new; membership + base access per tenant) -3. `team_members` (membership + scope per team) -4. `roles` (new) -5. `permissions` (new) -6. `role_bindings` (new; who has which role in which scope) +3. `business_memberships` (new; user access to business account scope) +4. `vendor_memberships` (new; user access to vendor account scope) +5. `team_members` (membership + scope per team) +6. `roles` (new) +7. `permissions` (new) +8. `role_bindings` (new; who has which role in which scope) Rules: 1. Unique tenant membership: `(tenant_id, user_id)`. -2. Unique team membership: `(team_id, user_id)`. -3. Access checks resolve through tenant membership first, then optional team/hub scope. +2. Unique business membership: `(business_id, user_id)`. +3. Unique vendor membership: `(vendor_id, user_id)`. +4. Unique team membership: `(team_id, user_id)`. +5. Access checks resolve through tenant membership first, then business/vendor/team scope. ## 4.2 Organization and Tenant Tables: @@ -121,6 +126,7 @@ Tables: Rules: 1. Every command-critical row references `tenant_id`. 2. All list queries must include tenant predicate. +3. Business and vendor routes must enforce membership scope before data access. ## 4.8 RBAC rollout strategy (deferred enforcement) RBAC should be introduced in phases and **not enforced everywhere immediately**. @@ -250,6 +256,10 @@ erDiagram TENANT ||--o{ TEAM : owns TEAM ||--o{ TEAM_MEMBER : has USER ||--o{ TEAM_MEMBER : belongs_to + USER ||--o{ BUSINESS_MEMBERSHIP : belongs_to + USER ||--o{ VENDOR_MEMBERSHIP : belongs_to + BUSINESS ||--o{ BUSINESS_MEMBERSHIP : has + VENDOR ||--o{ VENDOR_MEMBERSHIP : has BUSINESS ||--o{ ORDER : requests VENDOR ||--o{ ORDER : fulfills From fd3aec51827ffc9382f48f659aa0f72bc3ace0c7 Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:02:56 -0500 Subject: [PATCH 19/19] docs(m4): add model keys and relationship diagrams catalog --- CHANGELOG.md | 1 + .../m4-target-schema-models-and-keys.md | 264 ++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 docs/MILESTONES/M4/planning/m4-target-schema-models-and-keys.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7db650..b3b40911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,3 +27,4 @@ | 2026-02-25 | 0.1.22 | Updated core actor scenarios with explicit business and vendor user partitioning via membership tables. | | 2026-02-25 | 0.1.23 | Updated schema blueprint and reconciliation docs to add `business_memberships` and `vendor_memberships` as first-class data actors. | | 2026-02-25 | 0.1.24 | Removed stale `m4-discrepencies.md` document from M4 planning docs cleanup. | +| 2026-02-25 | 0.1.25 | Added target schema model catalog with keys and domain relationship diagrams for slide/workshop use. | diff --git a/docs/MILESTONES/M4/planning/m4-target-schema-models-and-keys.md b/docs/MILESTONES/M4/planning/m4-target-schema-models-and-keys.md new file mode 100644 index 00000000..ebfb3dc8 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-target-schema-models-and-keys.md @@ -0,0 +1,264 @@ +# M4 Target Schema Models, Keys, and Relationships + +Status: Draft for architecture workshop +Date: 2026-02-25 +Owner: Technical Lead + +## 1) Purpose +This document is the model-level view for slide creation: +1. Key fields per model (`PK`, `FK`, unique keys). +2. How models relate to each other. +3. A first-principles structure for scale across tenant, business, vendor, and workforce flows. + +## 2) Identity and access models + +### 2.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `users` | `id` | - | `email` (optional unique) | +| `tenants` | `id` | - | `slug` | +| `tenant_memberships` | `id` | `tenant_id -> tenants.id`, `user_id -> users.id` | `(tenant_id, user_id)` | +| `business_memberships` | `id` | `tenant_id -> tenants.id`, `business_id -> businesses.id`, `user_id -> users.id` | `(business_id, user_id)` | +| `vendor_memberships` | `id` | `tenant_id -> tenants.id`, `vendor_id -> vendors.id`, `user_id -> users.id` | `(vendor_id, user_id)` | +| `roles` | `id` | `tenant_id -> tenants.id` | `(tenant_id, name)` | +| `permissions` | `id` | - | `code` | +| `role_bindings` | `id` | `tenant_id -> tenants.id`, `role_id -> roles.id`, `user_id -> users.id` | `(tenant_id, role_id, user_id, scope_type, scope_id)` | + +### 2.2 Diagram + +```mermaid +erDiagram + USERS ||--o{ TENANT_MEMBERSHIPS : has + TENANTS ||--o{ TENANT_MEMBERSHIPS : has + + USERS ||--o{ BUSINESS_MEMBERSHIPS : has + BUSINESSES ||--o{ BUSINESS_MEMBERSHIPS : has + TENANTS ||--o{ BUSINESS_MEMBERSHIPS : scopes + + USERS ||--o{ VENDOR_MEMBERSHIPS : has + VENDORS ||--o{ VENDOR_MEMBERSHIPS : has + TENANTS ||--o{ VENDOR_MEMBERSHIPS : scopes + + TENANTS ||--o{ ROLES : defines + ROLES ||--o{ ROLE_BINDINGS : used_by + USERS ||--o{ ROLE_BINDINGS : receives + TENANTS ||--o{ ROLE_BINDINGS : scopes +``` +``` + +## 3) Organization and stakeholder models + +### 3.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `businesses` | `id` | `tenant_id -> tenants.id` | `(tenant_id, business_name)` | +| `vendors` | `id` | `tenant_id -> tenants.id` | `(tenant_id, company_name)` | +| `teams` | `id` | `tenant_id -> tenants.id` | `(tenant_id, team_name)` | +| `team_hubs` | `id` | `team_id -> teams.id` | `(team_id, hub_name)` | +| `team_hud_departments` | `id` | `team_hub_id -> team_hubs.id` | `(team_hub_id, name)` | +| `stakeholder_types` | `id` | - | `code` | +| `stakeholder_profiles` | `id` | `tenant_id -> tenants.id`, `stakeholder_type_id -> stakeholder_types.id` | `(tenant_id, stakeholder_type_id, name)` | +| `stakeholder_links` | `id` | `tenant_id -> tenants.id`, `from_profile_id -> stakeholder_profiles.id`, `to_profile_id -> stakeholder_profiles.id` | `(tenant_id, from_profile_id, to_profile_id, relation_type)` | + +### 3.2 Diagram + +```mermaid +erDiagram + TENANTS ||--o{ BUSINESSES : owns + TENANTS ||--o{ VENDORS : owns + TENANTS ||--o{ TEAMS : owns + TEAMS ||--o{ TEAM_HUBS : has + TEAM_HUBS ||--o{ TEAM_HUD_DEPARTMENTS : has + + STAKEHOLDER_TYPES ||--o{ STAKEHOLDER_PROFILES : classifies + TENANTS ||--o{ STAKEHOLDER_PROFILES : owns + STAKEHOLDER_PROFILES ||--o{ STAKEHOLDER_LINKS : from + STAKEHOLDER_PROFILES ||--o{ STAKEHOLDER_LINKS : to + TENANTS ||--o{ STAKEHOLDER_LINKS : scopes +``` +``` + +## 4) Workforce, orders, and assignments + +### 4.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `staffs` | `id` | `tenant_id -> tenants.id`, `user_id -> users.id` | `(tenant_id, user_id)` (nullable until activation if needed) | +| `staff_roles` | `id` | `staff_id -> staffs.id`, `role_id -> roles.id` | `(staff_id, role_id)` | +| `workforce` | `id` | `tenant_id -> tenants.id`, `vendor_id -> vendors.id`, `staff_id -> staffs.id` | active `(vendor_id, staff_id)` | +| `orders` | `id` | `tenant_id -> tenants.id`, `business_id -> businesses.id`, `vendor_id -> vendors.id` | `(tenant_id, external_ref)` optional | +| `order_schedule_rules` | `id` | `order_id -> orders.id` | `(order_id, rule_type, effective_from)` | +| `shifts` | `id` | `tenant_id -> tenants.id`, `order_id -> orders.id` | `(order_id, start_time, end_time)` | +| `shift_roles` | `id` | `shift_id -> shifts.id`, `role_id -> roles.id` | `(shift_id, role_id)` | +| `shift_managers` | `id` | `shift_id -> shifts.id`, `team_member_id -> team_members.id` | `(shift_id, team_member_id)` | +| `applications` | `id` | `tenant_id -> tenants.id`, `shift_id -> shifts.id`, `role_id -> roles.id`, `staff_id -> staffs.id` | `(shift_id, role_id, staff_id)` | +| `assignments` | `id` | `tenant_id -> tenants.id`, `shift_role_id -> shift_roles.id`, `workforce_id -> workforce.id` | `(shift_role_id, workforce_id)` active | + +### 4.2 Diagram + +```mermaid +erDiagram + TENANTS ||--o{ STAFFS : owns + USERS ||--o| STAFFS : identity + STAFFS ||--o{ STAFF_ROLES : has + ROLES ||--o{ STAFF_ROLES : classifies + + TENANTS ||--o{ ORDERS : scopes + BUSINESSES ||--o{ ORDERS : requests + VENDORS ||--o{ ORDERS : fulfills + ORDERS ||--o{ ORDER_SCHEDULE_RULES : configures + ORDERS ||--o{ SHIFTS : expands_to + SHIFTS ||--o{ SHIFT_ROLES : requires + ROLES ||--o{ SHIFT_ROLES : typed_as + SHIFTS ||--o{ SHIFT_MANAGERS : has + + VENDORS ||--o{ WORKFORCE : contracts + STAFFS ||--o{ WORKFORCE : linked + + SHIFT_ROLES ||--o{ APPLICATIONS : receives + STAFFS ||--o{ APPLICATIONS : applies + SHIFT_ROLES ||--o{ ASSIGNMENTS : allocates + WORKFORCE ||--o{ ASSIGNMENTS : executes +``` +``` + +## 5) Attendance and offense governance + +### 5.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `attendance_events` | `id` | `tenant_id -> tenants.id`, `assignment_id -> assignments.id` | `(assignment_id, source_event_id)` | +| `attendance_sessions` | `id` | `tenant_id -> tenants.id`, `assignment_id -> assignments.id` | one open session per assignment | +| `timesheets` | `id` | `tenant_id -> tenants.id`, `assignment_id -> assignments.id`, `staff_id -> staffs.id` | `(assignment_id)` | +| `timesheet_adjustments` | `id` | `timesheet_id -> timesheets.id`, `actor_user_id -> users.id` | - | +| `offense_policies` | `id` | `tenant_id -> tenants.id`, `business_id -> businesses.id` nullable | `(tenant_id, name, business_id)` | +| `offense_rules` | `id` | `policy_id -> offense_policies.id` | `(policy_id, trigger_type, threshold_count)` | +| `offense_events` | `id` | `tenant_id -> tenants.id`, `staff_id -> staffs.id`, `assignment_id -> assignments.id` nullable, `rule_id -> offense_rules.id` nullable | `(staff_id, occurred_at, offense_type)` | +| `enforcement_actions` | `id` | `offense_event_id -> offense_events.id`, `actor_user_id -> users.id` | - | + +### 5.2 Diagram + +```mermaid +erDiagram + ASSIGNMENTS ||--o{ ATTENDANCE_EVENTS : emits + ASSIGNMENTS ||--o{ ATTENDANCE_SESSIONS : opens + ASSIGNMENTS ||--o{ TIMESHEETS : settles + TIMESHEETS ||--o{ TIMESHEET_ADJUSTMENTS : adjusts + USERS ||--o{ TIMESHEET_ADJUSTMENTS : made_by + + TENANTS ||--o{ OFFENSE_POLICIES : defines + BUSINESSES ||--o{ OFFENSE_POLICIES : overrides + OFFENSE_POLICIES ||--o{ OFFENSE_RULES : contains + STAFFS ||--o{ OFFENSE_EVENTS : incurs + OFFENSE_RULES ||--o{ OFFENSE_EVENTS : triggered_by + OFFENSE_EVENTS ||--o{ ENFORCEMENT_ACTIONS : causes + USERS ||--o{ ENFORCEMENT_ACTIONS : applied_by +``` +``` + +## 6) Compliance and verification + +### 6.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `documents` | `id` | `tenant_id -> tenants.id` | `(tenant_id, document_type, name)` | +| `staff_documents` | `id` | `staff_id -> staffs.id`, `document_id -> documents.id` | `(staff_id, document_id)` | +| `certificates` | `id` | `staff_id -> staffs.id` | `(staff_id, certificate_number)` optional | +| `compliance_requirements` | `id` | `tenant_id -> tenants.id`, `business_id -> businesses.id` nullable, `role_id -> roles.id` nullable | `(tenant_id, requirement_type, business_id, role_id)` | +| `verification_jobs` | `id` | `tenant_id -> tenants.id`, `staff_id -> staffs.id`, `document_id -> documents.id` nullable | `(tenant_id, idempotency_key)` | +| `verification_reviews` | `id` | `verification_job_id -> verification_jobs.id`, `reviewer_user_id -> users.id` | - | +| `verification_events` | `id` | `verification_job_id -> verification_jobs.id` | - | + +### 6.2 Diagram + +```mermaid +erDiagram + STAFFS ||--o{ STAFF_DOCUMENTS : uploads + DOCUMENTS ||--o{ STAFF_DOCUMENTS : references + STAFFS ||--o{ CERTIFICATES : has + + TENANTS ||--o{ COMPLIANCE_REQUIREMENTS : defines + BUSINESSES ||--o{ COMPLIANCE_REQUIREMENTS : overrides + ROLES ||--o{ COMPLIANCE_REQUIREMENTS : role_scope + + STAFFS ||--o{ VERIFICATION_JOBS : subject + VERIFICATION_JOBS ||--o{ VERIFICATION_REVIEWS : reviewed + VERIFICATION_JOBS ||--o{ VERIFICATION_EVENTS : logs + USERS ||--o{ VERIFICATION_REVIEWS : reviewer +``` +``` + +## 7) Finance and settlement + +### 7.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `invoices` | `id` | `tenant_id -> tenants.id`, `order_id -> orders.id`, `business_id -> businesses.id`, `vendor_id -> vendors.id` | `(tenant_id, invoice_number)` | +| `invoice_line_items` | `id` | `invoice_id -> invoices.id`, `assignment_id -> assignments.id` nullable | `(invoice_id, line_number)` | +| `invoice_status_history` | `id` | `invoice_id -> invoices.id`, `actor_user_id -> users.id` | - | +| `payment_runs` | `id` | `tenant_id -> tenants.id` | `(tenant_id, run_reference)` | +| `payment_allocations` | `id` | `payment_run_id -> payment_runs.id`, `invoice_id -> invoices.id` | `(payment_run_id, invoice_id)` | +| `remittance_documents` | `id` | `payment_run_id -> payment_runs.id` | `(payment_run_id, document_url)` | +| `account_token_refs` | `id` | `tenant_id -> tenants.id`, `owner_business_id -> businesses.id` nullable, `owner_vendor_id -> vendors.id` nullable | one primary per owner | + +### 7.2 Diagram + +```mermaid +erDiagram + ORDERS ||--o{ INVOICES : billed + BUSINESSES ||--o{ INVOICES : billed_to + VENDORS ||--o{ INVOICES : billed_from + INVOICES ||--o{ INVOICE_LINE_ITEMS : contains + INVOICES ||--o{ INVOICE_STATUS_HISTORY : transitions + USERS ||--o{ INVOICE_STATUS_HISTORY : changed_by + + PAYMENT_RUNS ||--o{ PAYMENT_ALLOCATIONS : allocates + INVOICES ||--o{ PAYMENT_ALLOCATIONS : receives + PAYMENT_RUNS ||--o{ REMITTANCE_DOCUMENTS : publishes + + TENANTS ||--o{ ACCOUNT_TOKEN_REFS : scopes + BUSINESSES ||--o{ ACCOUNT_TOKEN_REFS : business_owner + VENDORS ||--o{ ACCOUNT_TOKEN_REFS : vendor_owner +``` +``` + +## 8) Audit and reliability + +### 8.1 Model keys + +| Model | Primary key | Foreign keys | Important unique keys | +|---|---|---|---| +| `domain_events` | `id` | `tenant_id -> tenants.id`, `actor_user_id -> users.id` | `(tenant_id, aggregate_type, aggregate_id, sequence)` | +| `idempotency_keys` | `id` | `tenant_id -> tenants.id`, `actor_user_id -> users.id` | `(tenant_id, actor_user_id, route, key)` | +| `activity_logs` | `id` | `tenant_id -> tenants.id`, `user_id -> users.id` | `(tenant_id, created_at, id)` | + +### 8.2 Diagram + +```mermaid +erDiagram + TENANTS ||--o{ DOMAIN_EVENTS : scopes + USERS ||--o{ DOMAIN_EVENTS : actor + TENANTS ||--o{ IDEMPOTENCY_KEYS : scopes + USERS ||--o{ IDEMPOTENCY_KEYS : actor + TENANTS ||--o{ ACTIVITY_LOGS : scopes + USERS ||--o{ ACTIVITY_LOGS : actor +``` +``` + +## 9) Practical note for current system +Current schema already has: +1. `businesses.userId` (single business owner user). +2. `vendors.userId` (single vendor owner user). +3. `team_members` (multi-user workaround). + +Target schema improves this by adding explicit: +1. `business_memberships` +2. `vendor_memberships` + +This is the key upgrade for clean client/vendor user partitioning.