From 270e95f1560bcb8131e00e173360e0ecedec1831 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 15:05:12 +0100 Subject: [PATCH 1/2] Add NestJS shipment tracker with transactional events and CDC reports --- .github/workflows/shipment-tracker.yml | 35 + applications/shipment-tracker/.env.example | 12 + applications/shipment-tracker/.gitignore | 5 + applications/shipment-tracker/.node-version | 1 + applications/shipment-tracker/README.md | 248 ++ .../infra/clickpipe-events.json | 11 + .../shipment-tracker/migrations/initial.ts | 66 + .../shipment-tracker/package-lock.json | 2392 +++++++++++++++++ applications/shipment-tracker/package.json | 35 + .../shipment-tracker/scripts/start-runtime.sh | 9 + .../shipment-tracker/sql/bootstrap.sql | 16 + .../shipment-tracker/sql/publication.sql | 7 + applications/shipment-tracker/src/admin.ts | 36 + applications/shipment-tracker/src/app.ts | 46 + applications/shipment-tracker/src/auth.ts | 45 + applications/shipment-tracker/src/config.ts | 36 + applications/shipment-tracker/src/domain.ts | 32 + applications/shipment-tracker/src/dto.ts | 29 + applications/shipment-tracker/src/entities.ts | 50 + applications/shipment-tracker/src/main.ts | 35 + applications/shipment-tracker/src/reports.ts | 56 + .../shipment-tracker/src/shipments.ts | 75 + .../shipment-tracker/test/cloud-check.mjs | 139 + .../shipment-tracker/test/pipeline-check.mjs | 117 + .../shipment-tracker/test/process-restart.mjs | 61 + .../shipment-tracker/test/security-check.mjs | 44 + .../shipment-tracker/test/validation.test.ts | 46 + applications/shipment-tracker/tsconfig.json | 9 + 28 files changed, 3693 insertions(+) create mode 100644 .github/workflows/shipment-tracker.yml create mode 100644 applications/shipment-tracker/.env.example create mode 100644 applications/shipment-tracker/.gitignore create mode 100644 applications/shipment-tracker/.node-version create mode 100644 applications/shipment-tracker/README.md create mode 100644 applications/shipment-tracker/infra/clickpipe-events.json create mode 100644 applications/shipment-tracker/migrations/initial.ts create mode 100644 applications/shipment-tracker/package-lock.json create mode 100644 applications/shipment-tracker/package.json create mode 100755 applications/shipment-tracker/scripts/start-runtime.sh create mode 100644 applications/shipment-tracker/sql/bootstrap.sql create mode 100644 applications/shipment-tracker/sql/publication.sql create mode 100644 applications/shipment-tracker/src/admin.ts create mode 100644 applications/shipment-tracker/src/app.ts create mode 100644 applications/shipment-tracker/src/auth.ts create mode 100644 applications/shipment-tracker/src/config.ts create mode 100644 applications/shipment-tracker/src/domain.ts create mode 100644 applications/shipment-tracker/src/dto.ts create mode 100644 applications/shipment-tracker/src/entities.ts create mode 100644 applications/shipment-tracker/src/main.ts create mode 100644 applications/shipment-tracker/src/reports.ts create mode 100644 applications/shipment-tracker/src/shipments.ts create mode 100644 applications/shipment-tracker/test/cloud-check.mjs create mode 100644 applications/shipment-tracker/test/pipeline-check.mjs create mode 100644 applications/shipment-tracker/test/process-restart.mjs create mode 100644 applications/shipment-tracker/test/security-check.mjs create mode 100644 applications/shipment-tracker/test/validation.test.ts create mode 100644 applications/shipment-tracker/tsconfig.json diff --git a/.github/workflows/shipment-tracker.yml b/.github/workflows/shipment-tracker.yml new file mode 100644 index 00000000..8f379e56 --- /dev/null +++ b/.github/workflows/shipment-tracker.yml @@ -0,0 +1,35 @@ +name: Shipment Tracker checks + +on: + pull_request: + paths: + - 'applications/shipment-tracker/**' + - '.github/workflows/shipment-tracker.yml' + push: + branches: [main] + paths: + - 'applications/shipment-tracker/**' + - '.github/workflows/shipment-tracker.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: applications/shipment-tracker + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.21.0 + cache: npm + cache-dependency-path: applications/shipment-tracker/package-lock.json + - run: npm ci --ignore-scripts + - run: npm run typecheck + - run: npm test + +# Cloud mutation/CDC acceptance is a separate explicit fixture run; see the README. diff --git a/applications/shipment-tracker/.env.example b/applications/shipment-tracker/.env.example new file mode 100644 index 00000000..58b01195 --- /dev/null +++ b/applications/shipment-tracker/.env.example @@ -0,0 +1,12 @@ +PGHOST=your-managed-postgres-host +PGPORT=5432 +PGDATABASE=postgres +PGUSER=shipments_app +PGPASSWORD=replace-runtime-password +PGSSLROOTCERT=/absolute/path/to/cloud-ca.pem +CLICKHOUSE_URL=https://your-analytical-service:8443 +CLICKHOUSE_USER=shipments_reports +CLICKHOUSE_PASSWORD=replace-report-password +ACCOUNT_TOKENS={"00000000-0000-4000-8000-000000000001":"replace-with-a-random-token-at-least-32-characters"} +PORT=3000 +# Migration/CDC/admin credentials belong in separate setup files, not this runtime file. diff --git a/applications/shipment-tracker/.gitignore b/applications/shipment-tracker/.gitignore new file mode 100644 index 00000000..c161cc87 --- /dev/null +++ b/applications/shipment-tracker/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.deployment/ +.env +*.log diff --git a/applications/shipment-tracker/.node-version b/applications/shipment-tracker/.node-version new file mode 100644 index 00000000..df6ae337 --- /dev/null +++ b/applications/shipment-tracker/.node-version @@ -0,0 +1 @@ +24.21.0 diff --git a/applications/shipment-tracker/README.md b/applications/shipment-tracker/README.md new file mode 100644 index 00000000..499b5e08 --- /dev/null +++ b/applications/shipment-tracker/README.md @@ -0,0 +1,248 @@ +# NestJS shipment tracker + +A small account-scoped API tracks seeded shipments through `created → dispatched → delivered`, with cancellation from `created` or `dispatched`. ClickHouse Managed Postgres (public beta) stores current state and immutable accepted transition events. ClickPipes copies those events to analytical ClickHouse; the official Node.js client reads bounded delivery reports. All database services run in ClickHouse Cloud. There is no carrier, address, payment or tracking-provider integration. + +Shipment creation is a seed/admin operation. The HTTP API provides reads, history and lifecycle transitions. A static server-configured bearer token identifies an account; callers cannot choose their account. The server binds to loopback by default. Deploy behind trusted HTTPS and replace demo tokens with proper identity and access policies before public use. + +## Workflow and boundaries + +| Route | Behavior | +|---|---| +| `GET /shipments?limit=20&after=` | Account-scoped live UUID keyset pagination; maximum 50 rows | +| `GET /shipments/:id` | Current authoritative Postgres state or 404 | +| `GET /shipments/:id/events?limit=20&afterRevision=0` | Account-scoped accepted history; maximum 50 rows | +| `POST /shipments/:id/transitions` | 201 for a new event; 200 for an identical retained retry; 409 for stale revision, invalid lifecycle or reused ID with different data | +| `GET /reports?from=YYYY-MM-DD&to=YYYY-MM-DD` | Eventually consistent daily transition/delivery summaries for the authenticated account | + +A transition body contains only `requestId`, integer `expectedRevision` and `toStatus`. Request IDs contain 1–64 ASCII letters, digits, `.`, `_` or `-`. The revision range is 0–9,999 on input. Bodies are limited to 4 KiB, unknown fields are rejected, and malformed JSON returns 400. UUID paths are normalized to lowercase for matching retries. + +The transaction locks the account, checks its retained request ID, then locks the scoped shipment. It checks the supplied revision and lifecycle, updates the shipment and inserts the event using **that transaction's TypeORM manager**. A failed insert rolls back the earlier update. The same ID and data return the original event even after later transitions or process restart. Changing the shipment, expected revision or target status conflicts. IDs are unique across all shipments and dates within one account and retained indefinitely here. Deleting events would release retry protection; production retention needs an explicit coordinated policy. After a lost response, retry the same data and ID: the first observed success may be 200. + +Account-first locking serializes every transition for one account, including transitions of different shipments. It keeps account-wide retry semantics simple; different accounts can progress independently. Every transaction uses the same lock order. No throughput claim is made. The shared runtime role is trusted application code, not a database tenant boundary: HTTP authentication supplies account scope. Do not distribute database credentials to clients. + +A single server timestamp is captured after the locks. Accepted event time and UTC `event_day` derive from it. Dispatch records this timestamp on the shipment and event. Delivery copies the dispatch timestamp and records the integer millisecond difference in its terminal event. This measures elapsed time between **API-accepted** dispatch and delivery, not a carrier measurement; it can cross UTC midnight. Clock movement that would produce a negative duration rejects the transition. Non-delivered events use zero and are excluded from duration aggregates. + +The list sorts by immutable UUID, not mutable status/time. Inserts whose UUID precedes a cursor are absent from later pages; this is live traversal, not a snapshot. History sorts by per-shipment revision. Delivered and cancelled states are terminal. + +## Build + +Use Node.js 24.21.0, npm 11.19.0, `psql`, OpenSSL, curl, jq and [clickhousectl](https://clickhouse.com/docs/concepts/features/interfaces/cli). Run commands from this directory in Linux. `.node-version` and `package-lock.json` pin the runtime intent and dependency graph; no database credentials are needed: + +```sh +npm ci +npm run typecheck +npm test +``` + +The verified stack is NestJS 12.1.2, its TypeORM adapter 12.0.2, TypeORM 1.1.1, node-postgres 8.23.1, TypeScript 7.0.2 and `@clickhouse/client` 1.23.1. Four unit tests cover strict inputs, lifecycle/duration/UTC boundaries and token identity. Nest's modules, guard, validation pipe and TypeORM integration are used directly; entity schemas describe rows. `synchronize: false` and explicit migrations keep startup separate from schema changes. See [Nest's transaction and migration guidance](https://docs.nestjs.com/data/typeorm). + +The PG pool has five connections, a 10-second connection limit, 20-second statement timeout and 25-second query timeout. These are layer controls, not an HTTP response-time promise. Temporary failures return a generic 503 without SQL or credentials in the response. Operational startup does not require an analytical query to succeed. + +## Create the Cloud services + +These resources incur charges. Use dedicated names, protect receipts, and delete your resources after validation. The tested small PG shape is `c6gd.large`, AWS `us-east-1`, PostgreSQL 18 with no HA. Check current availability and pricing. The analytical service uses the CLI's current minimum 8 GiB per replica, with two replicas for a new warehouse. + +```sh +umask 077 +mkdir -p .deployment +export ORG_ID='your-organization-id' +export DEV_EGRESS_IP='your-development-public-egress-ip' +clickhousectl cloud postgres create --org-id "$ORG_ID" \ + --name shipment-tracker-pg --provider aws --region us-east-1 \ + --size c6gd.large --pg-version 18 --ha-type none --json \ + > .deployment/postgres-create.json +export PG_ID=$(jq -er .id .deployment/postgres-create.json) +clickhousectl cloud service create --org-id "$ORG_ID" \ + --name shipment-tracker-analytics --provider aws --region us-east-1 \ + --min-replica-memory-gb 8 --max-replica-memory-gb 8 --num-replicas 2 \ + --idle-scaling false --ip-allow "$DEV_EGRESS_IP/32=shipment-tracker-dev" --json \ + > .deployment/clickhouse-create.json +export CH_ID=$(jq -er .service.id .deployment/clickhouse-create.json) +``` + +Poll `cloud postgres get "$PG_ID"` and `cloud service get "$CH_ID"`, with `--org-id "$ORG_ID" --json`, until both states are `running`. Use a finite deadline, for example ten minutes, and stop on failure. `get` does not return the original passwords. Restrict the analytical allowlist to your development egress; ClickPipes manages its destination connectivity. Keep Cloud organization API credentials out of application runtime files. + +## Bootstrap roles, migrations and seed + +```sh +clickhousectl cloud postgres certs get "$PG_ID" --org-id "$ORG_ID" \ + --output .deployment/postgres-ca.pem +export PGHOST=$(jq -er .hostname .deployment/postgres-create.json) +export PGPORT=5432 PGDATABASE=postgres PGSSLMODE=verify-full +export PGSSLROOTCERT="$PWD/.deployment/postgres-ca.pem" +export PGADMIN=$(jq -er .username .deployment/postgres-create.json) +export PGPASSWORD=$(jq -er .password .deployment/postgres-create.json) +export SHIPMENTS_MIGRATOR_PASSWORD="Aa1_$(openssl rand -hex 24)" +export SHIPMENTS_APP_PASSWORD="Aa1_$(openssl rand -hex 24)" +export SHIPMENTS_CDC_PASSWORD="Aa1_$(openssl rand -hex 24)" +psql -X -v ON_ERROR_STOP=1 -U "$PGADMIN" -f sql/bootstrap.sql +PGUSER=shipments_migrator PGPASSWORD="$SHIPMENTS_MIGRATOR_PASSWORD" npm run migrate +PGUSER=shipments_migrator PGPASSWORD="$SHIPMENTS_MIGRATOR_PASSWORD" npm run seed +psql -X -v ON_ERROR_STOP=1 -U "$PGADMIN" -f sql/publication.sql +``` + +Bootstrap creates a non-login schema owner, a migration login, a runtime login and an event-only replication login. The migrator assumes the owner for schema/seed operations. Run bootstrap once. Repeating `migrate` is a no-op; repeating `seed` preserves current state. Seed creates six shipments in the North account and three in the South account. Account/shipment UUIDs are visible in `src/admin.ts`. + +`PGUSER=shipments_migrator PGPASSWORD="$SHIPMENTS_MIGRATOR_PASSWORD" npm run revert` drops all application tables and data. A lifecycle check belongs **before** accepted events and pipe creation: revert, migrate and seed again. If publication already exists, dropping its table removes membership; restore it with administrator credentials using `ALTER PUBLICATION shipments_events_clickpipe ADD TABLE shipments.events` instead of rerunning the publication creation script. + +Runtime can read accounts/shipments/events, update only current-state fields, insert events, and lock account rows using a narrowly granted `UPDATE(account_id)`. It cannot create tables, create shipments, update/delete events or access migration history. The composite account/shipment foreign key rejects cross-account event linkage. SQL CHECKs enforce lifecycle, UTC day, revision relationship and dispatch/delivery timestamp facts. Publication contains **only** events; accounts, credentials and current-state rows are not copied. + +## Analytical reader and runtime configuration + +```sh +export CLICKHOUSE_URL="https://$(jq -er '.service.endpoints[] | select(.protocol=="https") | .host' .deployment/clickhouse-create.json):$(jq -er '.service.endpoints[] | select(.protocol=="https") | .port' .deployment/clickhouse-create.json)" +export CH_REPORT_PASSWORD="Aa1_$(openssl rand -hex 24)" +printf 'url="%s/?max_execution_time=10&max_rows_to_read=1000000&max_bytes_to_read=100000000"\nuser="default:%s"\nfail-with-body\nsilent\nshow-error\n' \ + "$CLICKHOUSE_URL" "$(jq -er .password .deployment/clickhouse-create.json)" \ + > .deployment/ch-admin.conf +printf "CREATE USER shipments_reports IDENTIFIED BY '%s' SETTINGS readonly=2, max_execution_time=5, max_rows_to_read=1000000, max_bytes_to_read=100000000, max_result_rows=186, result_overflow_mode='throw', max_threads=2;" \ + "$CH_REPORT_PASSWORD" > .deployment/report-user.sql +curl --max-time 20 --config .deployment/ch-admin.conf --data-binary @.deployment/report-user.sql +export TOKEN_A=$(openssl rand -hex 32) TOKEN_B=$(openssl rand -hex 32) +export ACCOUNT_TOKENS=$(jq -cn --arg a "$TOKEN_A" --arg b "$TOKEN_B" \ + '{"00000000-0000-4000-8000-000000000001":$a,"00000000-0000-4000-8000-000000000002":$b}') +{ + printf 'export PGHOST=%q PGPORT=5432 PGDATABASE=postgres PGUSER=shipments_app\n' "$PGHOST" + printf 'export PGPASSWORD=%q PGSSLROOTCERT=%q\n' "$SHIPMENTS_APP_PASSWORD" "$PGSSLROOTCERT" + printf 'export CLICKHOUSE_URL=%q CLICKHOUSE_USER=shipments_reports CLICKHOUSE_PASSWORD=%q\n' "$CLICKHOUSE_URL" "$CH_REPORT_PASSWORD" + printf 'export ACCOUNT_TOKENS=%q\n' "$ACCOUNT_TOKENS" +} > .deployment/app.env +{ + printf 'export SHIPMENTS_MIGRATOR_PASSWORD=%q\n' "$SHIPMENTS_MIGRATOR_PASSWORD" + printf 'export SHIPMENTS_CDC_PASSWORD=%q\n' "$SHIPMENTS_CDC_PASSWORD" +} > .deployment/test-secrets.env +``` + +`readonly=2` prohibits writes while permitting bounded query settings. Grant table-only SELECT after ClickPipes creates the destination. Direct HTTPS administration creates no Cloud Query API credential. If using `cloud service query` instead, inventory and clean up its auto-created Query API credentials/endpoints; preserve the organization API key. + +Start a **fresh shell**, then load only runtime credentials: + +```sh +source .deployment/app.env +bash scripts/start-runtime.sh +``` + +The launcher whitelists runtime fields, so inherited setup credentials are excluded from the Node process. node-postgres verifies both downloaded CA and hostname (`rejectUnauthorized: true`); analytical connections use normally verified HTTPS. Both services are remote Cloud databases. + +In a second shell, load `.deployment/app.env` and derive its account token: + +```sh +source .deployment/app.env +export TOKEN_A=$(jq -er '."00000000-0000-4000-8000-000000000001"' <<< "$ACCOUNT_TOKENS") +curl -sS --fail-with-body -H "Authorization: Bearer $TOKEN_A" http://127.0.0.1:3000/shipments +curl -sS --fail-with-body -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"requestId":"snapshot-a-dispatch","expectedRevision":0,"toStatus":"dispatched"}' \ + http://127.0.0.1:3000/shipments/a0000000-0000-4000-8000-000000000001/transitions +curl -sS --fail-with-body -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"requestId":"snapshot-a-deliver","expectedRevision":1,"toStatus":"delivered"}' \ + http://127.0.0.1:3000/shipments/a0000000-0000-4000-8000-000000000001/transitions +``` + +Replaying the dispatch ID/body returns its original dispatch event with 200 even after delivery. A different ID based on revision 0 now conflicts: refetch current state before deciding a new action. Seeded shipment IDs include alphabetic characters so uppercase-path replay can be tested. + +## Create and inspect the ClickPipe + +Source has a NOT NULL unique replica-identity index `(account_id,event_day,event_id)`. These immutable columns also form the [custom ClickPipes ordering key](https://clickhouse.com/docs/integrations/clickpipes/postgres/ordering-keys). Dispatch timestamp is legitimately unknown before dispatch, so preserve its nullability; do not invent a sentinel timestamp. + +In the setup shell, with saved resource IDs and CDC password: + +```sh +clickhousectl cloud clickpipe create postgres "$CH_ID" --org-id "$ORG_ID" \ + --name shipment-tracker-events --host "$PGHOST" --port 5432 --pg-database postgres \ + --username shipments_cdc --password "$SHIPMENTS_CDC_PASSWORD" \ + --ca-certificate .deployment/postgres-ca.pem --publication-name shipments_events_clickpipe \ + --replication-mode cdc --sync-interval-seconds 10 --pull-batch-size 10000 \ + --initial-load-parallelism 1 --snapshot-parallel-tables 1 \ + --allow-nullable-columns true --delete-on-merge false \ + --table-mapping-json "$(cat infra/clickpipe-events.json)" --json \ + > .deployment/clickpipe-create.json +export PIPE_ID=$(jq -er .id .deployment/clickpipe-create.json) +``` + +Poll `cloud clickpipe get "$CH_ID" "$PIPE_ID" --org-id "$ORG_ID" --json` until `Running`, with a finite deadline. Snapshot includes events accepted before creation; CDC follows later commits. The ten-second configured interval is not a replication-latency guarantee. + +Before relying on the table, inspect databases, tables, columns/comments, keys, skipping indexes, bounded sample rows and the execution plan. For example: + +```sh +curl --max-time 20 --config .deployment/ch-admin.conf \ + --data-binary 'SHOW CREATE TABLE default.cdc_shipment_events' +curl --max-time 20 --config .deployment/ch-admin.conf \ + --data-binary "SELECT name,type FROM system.columns WHERE database='default' AND table='cdc_shipment_events' ORDER BY position LIMIT 30 FORMAT JSON" +curl --max-time 20 --config .deployment/ch-admin.conf \ + --data-binary 'GRANT SELECT ON default.cdc_shipment_events TO shipments_reports' +curl -sS --fail-with-body -H "Authorization: Bearer $TOKEN_A" http://127.0.0.1:3000/reports +``` + +The verified destination is `SharedReplacingMergeTree(...,_peerdb_version)` with ordering `(account_id,event_day,event_id)`, UUID identity, Date32 day, Int64 duration and nullable DateTime64 dispatch time. ClickPipes owns this versioned destination. Reports query `FINAL` and `_peerdb_is_deleted=0` to handle [replayed versions and tombstones](https://clickhouse.com/docs/integrations/clickpipes/postgres/deduplication). No forced `OPTIMIZE FINAL`, partitioning or naive incremental sum materialized view over raw CDC versions is needed. + +Reports default to seven UTC days and accept only a range within the last 31 days including today. One account × 31 days × two service levels × three accepted target states bounds output to 186 groups. Each row contains event count, delivered count, total duration and maximum duration. All 64-bit aggregates are strings using explicit `toString`; JSON consumers cannot silently lose integer precision. No dispatch/delivery join is needed because the terminal event contains its facts. + +One shared [official ClickHouse Node.js client](https://clickhouse.com/docs/integrations/language-clients/js/index) uses the configured HTTPS URL directly and closes on Nest shutdown. Typed placeholders and `query_params` bind account and dates. Interpolating caller input into SQL would introduce an SQL injection risk. Client settings are defaults and can be overridden per request; each report explicitly applies five-second execution, one-million-row / 100 MB scan and 186-result-row limits. The pool has two connections, ten-second request timeout and a 15-second abort signal. These layered limits do not promise an overall request duration. Bounded `JSONEachRow` results are consumed and closed. The app never inserts into ClickHouse. + +Missing or unavailable analytics cannot reject a valid transition. Report responses label `consistency: "eventual"` and `operationalAuthority: "postgres"`; operational state/history always come from Postgres. + +## Cloud acceptance checks + +The manual tests mutate the seeded fixture and require private setup credentials. They do not run in CI. To reproduce the recorded suite, start from a fresh seed, use `node test/cloud-check.mjs snapshot` **instead of** the sample transition commands, then create the pipe and grant SELECT. In a dedicated test shell: + +```sh +source .deployment/app.env +source .deployment/test-secrets.env +export TEST_MIGRATOR_PASSWORD="$SHIPMENTS_MIGRATOR_PASSWORD" +export TEST_CDC_PASSWORD="$SHIPMENTS_CDC_PASSWORD" +export TEST_CH_ADMIN_PASSWORD=$(jq -er .password .deployment/clickhouse-create.json) +openssl req -x509 -newkey rsa:2048 -nodes -keyout .deployment/wrong-ca.key \ + -out .deployment/wrong-ca.pem -days 1 -subj /CN=untrusted-shipment-test +export TEST_WRONG_CA="$PWD/.deployment/wrong-ca.pem" +node test/cloud-check.mjs snapshot # Run this once before pipe creation. +node test/cloud-check.mjs core +node test/security-check.mjs +node test/cloud-check.mjs equality +node test/pipeline-check.mjs reader +node test/process-restart.mjs +``` + +The fixture asserts one advance from two competing transitions, one event from ten simultaneous matching requests, retained/mismatched retries, uppercase UUID replay, account isolation, pagination/input/body bounds, and rollback after a forced event-insert failure. TLS and role tests prove accepted verified connections and denied untrusted CA, event mutation, DDL, cross-account FK and null dispatch timestamp. The restart test uses a separate port, confirms the old PID is gone and checks the child's environment before starting its replacement. Administrator test credentials never enter the application child. + +For pipeline delay/outage and tombstone checks: + +```sh +clickhousectl cloud clickpipe stop "$CH_ID" "$PIPE_ID" --org-id "$ORG_ID" +# Poll get until Paused before accepting the delayed fixture. +node test/pipeline-check.mjs lag +node test/pipeline-check.mjs outage +clickhousectl cloud clickpipe start "$CH_ID" "$PIPE_ID" --org-id "$ORG_ID" +# Poll get until Running; equality waits at most 180 seconds per account. +node test/cloud-check.mjs equality +node test/pipeline-check.mjs tombstone +node test/cloud-check.mjs equality +``` + +Outage is a controlled revocation of reporting SELECT: both slots fail with 503 while a PG transition succeeds, then both recover on the same client. The privileged tombstone test temporarily deletes and restores the exact fixture event through the owner role. It verifies the generated delete version and live-row exclusion; normal runtime event deletion remains prohibited. Final reports must equal current PG counts and duration aggregates for each account, including restoration. This is fixture maintenance evidence, not an application deletion feature. + +Verified on 2 October 2026 against PostgreSQL 18.6 and analytical ClickHouse 26.6.1.2191: clean migrations/reversal/seed, four unit controls, HTTP races and rollback, restricted roles/TLS, initial snapshot and subsequent CDC, paused and unavailable analytics, recovered reports, exact aggregate equality and a genuine runtime-only process restart. Private raw logs and resource receipts are retained outside the repository. No scale or replication-latency guarantee follows from these small checks. + +## Cleanup + +Stop the API first. Delete your ClickPipe before its source/destination services; confirm each ID is absent from the corresponding list. A running analytical service requires `--force` to stop, poll and delete; Postgres deletion has no such flag. + +```sh +clickhousectl cloud clickpipe delete "$CH_ID" "$PIPE_ID" --org-id "$ORG_ID" +# Remove any separately inventoried Query API credentials/endpoints, if created. +clickhousectl cloud postgres delete "$PG_ID" --org-id "$ORG_ID" +clickhousectl cloud service delete "$CH_ID" --org-id "$ORG_ID" --force +clickhousectl cloud postgres list --org-id "$ORG_ID" --json +clickhousectl cloud service list --org-id "$ORG_ID" --json +``` + +If keeping PG for other work, remove this fixture only with its saved admin credentials, after deleting its ClickPipe: + +```sh +source .deployment/app.env +PGUSER=$(jq -er .username .deployment/postgres-create.json) \ + PGPASSWORD=$(jq -er .password .deployment/postgres-create.json) PGSSLMODE=verify-full \ + psql -X -v ON_ERROR_STOP=1 \ + -c 'DROP PUBLICATION shipments_events_clickpipe; DROP SCHEMA shipments CASCADE; DROP ROLE shipments_app, shipments_cdc, shipments_migrator, shipments_owner;' +``` + +Review role dependencies before adapting cleanup to a shared database. Never delete an unrelated service or the organization's API credential. See the [ClickHouse Managed Postgres overview](https://clickhouse.com/docs/products/managed-postgres/overview) for service context. diff --git a/applications/shipment-tracker/infra/clickpipe-events.json b/applications/shipment-tracker/infra/clickpipe-events.json new file mode 100644 index 00000000..381f0692 --- /dev/null +++ b/applications/shipment-tracker/infra/clickpipe-events.json @@ -0,0 +1,11 @@ +{ + "sourceSchemaName": "shipments", + "sourceTable": "events", + "targetTable": "cdc_shipment_events", + "excludedColumns": [], + "sortingKeys": ["account_id", "event_day", "event_id"], + "useCustomSortingKey": true, + "partitionByExpr": "", + "partitionKey": "", + "tableEngine": "ReplacingMergeTree" +} diff --git a/applications/shipment-tracker/migrations/initial.ts b/applications/shipment-tracker/migrations/initial.ts new file mode 100644 index 00000000..46b6df74 --- /dev/null +++ b/applications/shipment-tracker/migrations/initial.ts @@ -0,0 +1,66 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Initial1700000000000 implements MigrationInterface { + async up(runner: QueryRunner): Promise { + await runner.query(` + CREATE TABLE shipments.accounts ( + account_id UUID PRIMARY KEY, + name TEXT NOT NULL UNIQUE + ); + CREATE TABLE shipments.shipments ( + shipment_id UUID PRIMARY KEY, + account_id UUID NOT NULL REFERENCES shipments.accounts(account_id), + service_level TEXT NOT NULL CHECK (service_level IN ('standard', 'express')), + status TEXT NOT NULL DEFAULT 'created' + CHECK (status IN ('created', 'dispatched', 'delivered', 'cancelled')), + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision BETWEEN 0 AND 10000), + dispatched_at TIMESTAMPTZ(3), + created_at TIMESTAMPTZ(3) NOT NULL, + updated_at TIMESTAMPTZ(3) NOT NULL, + UNIQUE (account_id, shipment_id), + CHECK ((status = 'created' AND dispatched_at IS NULL) OR + (status IN ('dispatched', 'delivered') AND dispatched_at IS NOT NULL) OR + status = 'cancelled') + ); + CREATE TABLE shipments.events ( + event_id UUID PRIMARY KEY, + account_id UUID NOT NULL, + shipment_id UUID NOT NULL, + request_id TEXT NOT NULL CHECK (length(request_id) BETWEEN 1 AND 64 AND request_id !~ '[^A-Za-z0-9._-]'), + expected_revision INTEGER NOT NULL CHECK (expected_revision BETWEEN 0 AND 9999), + revision INTEGER NOT NULL CHECK (revision = expected_revision + 1), + from_status TEXT NOT NULL, + to_status TEXT NOT NULL, + service_level TEXT NOT NULL CHECK (service_level IN ('standard', 'express')), + event_at TIMESTAMPTZ(3) NOT NULL, + event_day DATE NOT NULL CHECK (event_day = (event_at AT TIME ZONE 'UTC')::DATE), + dispatched_at TIMESTAMPTZ(3), + duration_ms BIGINT NOT NULL DEFAULT 0 CHECK (duration_ms >= 0), + FOREIGN KEY (account_id, shipment_id) + REFERENCES shipments.shipments(account_id, shipment_id), + UNIQUE (account_id, request_id), + UNIQUE (shipment_id, revision), + CHECK ((from_status = 'created' AND to_status IN ('dispatched', 'cancelled')) OR + (from_status = 'dispatched' AND to_status IN ('delivered', 'cancelled'))), + CHECK ((to_status = 'delivered' AND dispatched_at IS NOT NULL AND + event_at >= dispatched_at AND + duration_ms = extract(epoch FROM event_at - dispatched_at) * 1000) OR + (to_status <> 'delivered' AND duration_ms = 0)), + CHECK (to_status <> 'dispatched' OR + (dispatched_at IS NOT NULL AND dispatched_at = event_at)), + CHECK (from_status <> 'dispatched' OR dispatched_at IS NOT NULL) + ); + CREATE UNIQUE INDEX events_replica_identity + ON shipments.events(account_id, event_day, event_id); + ALTER TABLE shipments.events REPLICA IDENTITY USING INDEX events_replica_identity; + GRANT SELECT ON shipments.accounts, shipments.shipments, shipments.events TO shipments_app; + GRANT UPDATE (account_id) ON shipments.accounts TO shipments_app; + GRANT UPDATE (status, revision, dispatched_at, updated_at) ON shipments.shipments TO shipments_app; + GRANT INSERT ON shipments.events TO shipments_app; + GRANT SELECT ON shipments.events TO shipments_cdc; + `); + } + async down(runner: QueryRunner): Promise { + await runner.query('DROP TABLE shipments.events, shipments.shipments, shipments.accounts'); + } +} diff --git a/applications/shipment-tracker/package-lock.json b/applications/shipment-tracker/package-lock.json new file mode 100644 index 00000000..8a99de20 --- /dev/null +++ b/applications/shipment-tracker/package-lock.json @@ -0,0 +1,2392 @@ +{ + "name": "shipment-tracker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shipment-tracker", + "version": "1.0.0", + "dependencies": { + "@clickhouse/client": "1.23.1", + "@nestjs/common": "12.1.2", + "@nestjs/core": "12.1.2", + "@nestjs/platform-express": "12.1.2", + "@nestjs/typeorm": "12.0.2", + "class-transformer": "0.5.1", + "class-validator": "0.15.1", + "pg": "8.23.1", + "reflect-metadata": "0.2.2", + "rxjs": "7.8.2", + "typeorm": "1.1.1" + }, + "devDependencies": { + "@types/express": "5.0.6", + "@types/node": "24.19.1", + "@types/pg": "8.23.1", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=24.21.0 <25" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@clickhouse/client": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@clickhouse/client/-/client-1.23.1.tgz", + "integrity": "sha512-vs3/Zc1dHvT171btW5nMoPsPCJ6QVJ5pp7obxzO5sjqwFx/jjz9wwCAqcFOdc2DhprugDBaVn+4dVY8hG3A9nw==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@nestjs/common": { + "version": "12.1.2", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-12.1.2.tgz", + "integrity": "sha512-e2tvLcEaG18sJy5IXrdSLv0ZBpR5djWxypCLkLuMAEFCvVPYL1lEIhvAMBkarlg+ZA8tbZN40J0tFurUl9NpdA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "file-type": "22.1.1", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/core": { + "version": "12.1.2", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-12.1.2.tgz", + "integrity": "sha512-ieNRDv6P6dLsF/wMz9IUjwnyQbj7/Oluq1FXe3Xhje7TFSKPprLc83QniA2I6tjkr7YVDosdL2Dj7CVv9kU8og==", + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^12.0.0", + "@nestjs/microservices": "^12.0.0", + "@nestjs/platform-express": "^12.0.0", + "@nestjs/websockets": "^12.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-express": { + "version": "12.1.2", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-12.1.2.tgz", + "integrity": "sha512-//ftPv5+UONizm5zcme/AP91EFo1EsqFFyC2I3HC2w8swYVXadHKW1L3A2mgUuOhM1sdKkd2gzjQmL9504x85Q==", + "license": "MIT", + "dependencies": { + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.4.0", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^12.0.0", + "@nestjs/core": "^12.0.0" + } + }, + "node_modules/@nestjs/typeorm": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-12.0.2.tgz", + "integrity": "sha512-16b8s8mGkQdTH4i+dhhCwYkfuhWpLMdtuK82vOMjwa5ZpKYjFohU2jvbqkEJmx03fVO4HEpb0aw4VQXo+lxdKw==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0", + "typeorm": "^0.3.0 || ^1.0.0-dev" + } + }, + "node_modules/@sqltools/formatter": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.4.0.tgz", + "integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "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/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", + "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "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/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "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/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/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": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "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/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.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "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", + "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/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-type": { + "version": "22.1.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.1.1.tgz", + "integrity": "sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/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-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/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/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "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-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.14", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.14.tgz", + "integrity": "sha512-llihgCcx0BFLksecLP+x1J+6JDE1GsXS1RN/LoPF6qcwpeQcnjj0lcvZxY8AzbEpYwyZWPZW/nDuqkqzm3amiw==", + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.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": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/multer": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/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/multer/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/multer/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/multer/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/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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-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-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==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "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": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "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/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "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/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "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.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "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/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/sql-highlight": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz", + "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==", + "funding": [ + "https://github.com/scriptcoded/sql-highlight?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/scriptcoded" + } + ], + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "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/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-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "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/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "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": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typeorm": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-1.1.1.tgz", + "integrity": "sha512-og9mG4Lwlvj6MfvYd8yRCzXy73gETtwgTKQ/ISLYvEOpJPy4IrjU/89HK4ZAFmRIbEg1hkIk04v8phTrxfgPmQ==", + "license": "MIT", + "dependencies": { + "@sqltools/formatter": "^1.2.5", + "ansis": "^4.3.1", + "dayjs": "^1.11.21", + "debug": "^4.4.3", + "dedent": "^1.7.2", + "reflect-metadata": "^0.2.2", + "sql-highlight": "^6.1.0", + "tinyglobby": "^0.2.17", + "tslib": "^2.8.1", + "yargs": "^18.0.0" + }, + "bin": { + "typeorm": "cli.js", + "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", + "typeorm-ts-node-esm": "cli-ts-node-esm.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.11.0" + }, + "funding": { + "url": "https://opencollective.com/typeorm" + }, + "peerDependencies": { + "@google-cloud/spanner": "^8.0.0", + "@sap/hana-client": "^2.14.22", + "better-sqlite3": "^12.0.0", + "ioredis": "^5.0.4", + "mongodb": "^7.0.0", + "mssql": "^12.0.0", + "mysql2": "^3.15.3", + "oracledb": "^6.3.0 || ^7.0.0", + "pg": "^8.5.1", + "pg-native": "^3.0.0", + "pg-query-stream": "^4.0.0", + "redis": "^5.0.0 || ^6.0.0", + "sql.js": "^1.4.0", + "ts-node": "^10.9.2", + "typeorm-aurora-data-api-driver": "^3.0.0" + }, + "peerDependenciesMeta": { + "@google-cloud/spanner": { + "optional": true + }, + "@sap/hana-client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "redis": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "ts-node": { + "optional": true + }, + "typeorm-aurora-data-api-driver": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz", + "integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "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/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "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/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "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", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/applications/shipment-tracker/package.json b/applications/shipment-tracker/package.json new file mode 100644 index 00000000..0ef7ee5c --- /dev/null +++ b/applications/shipment-tracker/package.json @@ -0,0 +1,35 @@ +{ + "name": "shipment-tracker", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "engines": { "node": ">=24.21.0 <25" }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "npm run build && node --test dist/test/validation.test.js", + "start": "node dist/src/main.js", + "migrate": "node dist/src/admin.js up", + "revert": "node dist/src/admin.js down", + "seed": "node dist/src/admin.js seed" + }, + "dependencies": { + "@clickhouse/client": "1.23.1", + "@nestjs/common": "12.1.2", + "@nestjs/core": "12.1.2", + "@nestjs/platform-express": "12.1.2", + "@nestjs/typeorm": "12.0.2", + "class-transformer": "0.5.1", + "class-validator": "0.15.1", + "pg": "8.23.1", + "reflect-metadata": "0.2.2", + "rxjs": "7.8.2", + "typeorm": "1.1.1" + }, + "devDependencies": { + "@types/express": "5.0.6", + "@types/node": "24.19.1", + "@types/pg": "8.23.1", + "typescript": "7.0.2" + } +} diff --git a/applications/shipment-tracker/scripts/start-runtime.sh b/applications/shipment-tracker/scripts/start-runtime.sh new file mode 100755 index 00000000..03fd89de --- /dev/null +++ b/applications/shipment-tracker/scripts/start-runtime.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Load exported runtime fields first. This child receives no setup credentials. +set -euo pipefail +exec env -i PATH="$PATH" \ + PGHOST="${PGHOST:?}" PGPORT="${PGPORT:?}" PGDATABASE="${PGDATABASE:?}" \ + PGUSER="${PGUSER:?}" PGPASSWORD="${PGPASSWORD:?}" PGSSLROOTCERT="${PGSSLROOTCERT:?}" \ + CLICKHOUSE_URL="${CLICKHOUSE_URL:?}" CLICKHOUSE_USER="${CLICKHOUSE_USER:?}" \ + CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:?}" ACCOUNT_TOKENS="${ACCOUNT_TOKENS:?}" \ + PORT="${PORT:-3000}" node dist/src/main.js diff --git a/applications/shipment-tracker/sql/bootstrap.sql b/applications/shipment-tracker/sql/bootstrap.sql new file mode 100644 index 00000000..56f17aeb --- /dev/null +++ b/applications/shipment-tracker/sql/bootstrap.sql @@ -0,0 +1,16 @@ +\set ON_ERROR_STOP on +\getenv migrator_password SHIPMENTS_MIGRATOR_PASSWORD +\getenv app_password SHIPMENTS_APP_PASSWORD +\getenv cdc_password SHIPMENTS_CDC_PASSWORD +BEGIN; +CREATE ROLE shipments_owner NOLOGIN; +CREATE ROLE shipments_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD :'migrator_password'; +CREATE ROLE shipments_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD :'app_password'; +CREATE ROLE shipments_cdc LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE REPLICATION PASSWORD :'cdc_password'; +GRANT shipments_owner TO shipments_migrator; +GRANT shipments_owner TO CURRENT_USER; +CREATE SCHEMA shipments AUTHORIZATION shipments_owner; +REVOKE ALL ON SCHEMA shipments FROM PUBLIC; +GRANT USAGE ON SCHEMA shipments TO shipments_app, shipments_cdc; +ALTER ROLE shipments_app SET statement_timeout = '20s'; +COMMIT; diff --git a/applications/shipment-tracker/sql/publication.sql b/applications/shipment-tracker/sql/publication.sql new file mode 100644 index 00000000..f11408ef --- /dev/null +++ b/applications/shipment-tracker/sql/publication.sql @@ -0,0 +1,7 @@ +\set ON_ERROR_STOP on +DO $$ BEGIN + IF current_setting('wal_level') <> 'logical' THEN + RAISE EXCEPTION 'Logical replication required'; + END IF; +END $$; +CREATE PUBLICATION shipments_events_clickpipe FOR TABLE shipments.events; diff --git a/applications/shipment-tracker/src/admin.ts b/applications/shipment-tracker/src/admin.ts new file mode 100644 index 00000000..3f813230 --- /dev/null +++ b/applications/shipment-tracker/src/admin.ts @@ -0,0 +1,36 @@ +import 'reflect-metadata'; +import { DataSource } from 'typeorm'; +import { dbOptions } from './config.js'; +import { AccountSchema, ShipmentSchema } from './entities.js'; + +async function main(): Promise { + const source = await new DataSource(dbOptions(true)).initialize(); + try { + switch (process.argv[2]) { + case 'up': console.log('Applied migrations:', (await source.runMigrations()).length); break; + case 'down': await source.undoLastMigration(); console.log('Reverted latest migration'); break; + case 'seed': + await source.transaction(async manager => { + const accounts = [ + { id: '00000000-0000-4000-8000-000000000001', name: 'North warehouse' }, + { id: '00000000-0000-4000-8000-000000000002', name: 'South warehouse' }, + ]; + await manager.getRepository(AccountSchema).createQueryBuilder().insert().values(accounts).orIgnore().execute(); + const now = new Date(); + for (const [index, account] of accounts.entries()) { + for (let number = 1; number <= (index === 0 ? 6 : 3); number++) { + await manager.getRepository(ShipmentSchema).createQueryBuilder().insert().values({ + id: `${index === 0 ? 'a' : 'b'}0000000-0000-4000-8000-${String(number).padStart(12, '0')}`, + accountId: account.id, serviceLevel: number % 2 === 0 ? 'express' : 'standard', + status: 'created', revision: 0, dispatchedAt: null, createdAt: now, updatedAt: now, + }).orIgnore().execute(); + } + } + }); + console.log('Seeded two accounts and nine shipments; existing state retained'); + break; + default: throw new Error('Expected up, down or seed'); + } + } finally { await source.destroy(); } +} +main().catch(() => { console.error('Schema command failed; inspect credentials, TLS, role and migration state'); process.exitCode = 1; }); diff --git a/applications/shipment-tracker/src/app.ts b/applications/shipment-tracker/src/app.ts new file mode 100644 index 00000000..bd10d0a5 --- /dev/null +++ b/applications/shipment-tracker/src/app.ts @@ -0,0 +1,46 @@ +import { + Body, Controller, Get, Module, Param, ParseUUIDPipe, Post, Query, Req, Res, UseGuards, +} from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import type { Response } from 'express'; +import { AccountGuard, type AccountRequest } from './auth.js'; +import { dbOptions } from './config.js'; +import { TransitionDto, ListDto, HistoryDto, ReportDto } from './dto.js'; +import { ShipmentsService } from './shipments.js'; +import { ReportsService } from './reports.js'; + +@Controller() +@UseGuards(AccountGuard) +export class ShipmentController { + constructor(private readonly shipments: ShipmentsService, private readonly reports: ReportsService) {} + @Get('shipments') + list(@Req() request: AccountRequest, @Query() input: ListDto) { + return this.shipments.list(request.accountId, input); + } + @Get('shipments/:id') + get(@Req() request: AccountRequest, @Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { + return this.shipments.get(request.accountId, id.toLowerCase()); + } + @Get('shipments/:id/events') + history(@Req() request: AccountRequest, @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Query() input: HistoryDto) { + return this.shipments.history(request.accountId, id.toLowerCase(), input); + } + @Post('shipments/:id/transitions') + async transition(@Req() request: AccountRequest, + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Body() input: TransitionDto, @Res({ passthrough: true }) response: Response) { + const { created, event } = await this.shipments.transition(request.accountId, id.toLowerCase(), input); + response.status(created ? 201 : 200); + return event; + } + @Get('reports') + report(@Req() request: AccountRequest, @Query() input: ReportDto) { + return this.reports.report(request.accountId, input); + } +} +@Module({ + imports: [TypeOrmModule.forRootAsync({ useFactory: () => ({ ...dbOptions(), retryAttempts: 1 }) })], + controllers: [ShipmentController], providers: [AccountGuard, ShipmentsService, ReportsService], +}) +export class AppModule {} diff --git a/applications/shipment-tracker/src/auth.ts b/applications/shipment-tracker/src/auth.ts new file mode 100644 index 00000000..4d18964b --- /dev/null +++ b/applications/shipment-tracker/src/auth.ts @@ -0,0 +1,45 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { createHash, timingSafeEqual } from 'node:crypto'; +import type { Request } from 'express'; +import { required } from './config.js'; + +export type AccountRequest = Request & { accountId: string }; +export class TokenScopes { + private readonly entries: Array<{ digest: Buffer; accountId: string }>; + constructor(raw: string) { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Invalid token mapping'); + const entries = Object.entries(parsed); + if (entries.length < 1 || entries.length > 20) throw new Error('Expected 1–20 token scopes'); + const tokens = entries.map(([, value]) => value); + if (new Set(tokens).size !== tokens.length) throw new Error('Duplicate token'); + this.entries = entries.map(([accountId, token]) => { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(accountId) || + typeof token !== 'string' || !/^[A-Za-z0-9_-]{32,256}$/.test(token)) throw new Error('Invalid token scope'); + return { digest: createHash('sha256').update(token).digest(), accountId: accountId.toLowerCase() }; + }); + } + get accountIds(): string[] { return this.entries.map(({ accountId }) => accountId); } + account(token: string): string | undefined { + if (token.length > 256) return undefined; + const digest = createHash('sha256').update(token).digest(); + let found: string | undefined; + for (const entry of this.entries) { + if (timingSafeEqual(entry.digest, digest)) found = entry.accountId; + } + return found; + } +} +@Injectable() +export class AccountGuard implements CanActivate { + readonly scopes = new TokenScopes(required('ACCOUNT_TOKENS')); + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const header = request.headers.authorization; + const token = typeof header === 'string' && /^Bearer [A-Za-z0-9_-]+$/.test(header) ? header.slice(7) : ''; + const accountId = this.scopes.account(token); + if (!accountId) throw new UnauthorizedException('Invalid account token'); + request.accountId = accountId; + return true; + } +} diff --git a/applications/shipment-tracker/src/config.ts b/applications/shipment-tracker/src/config.ts new file mode 100644 index 00000000..19bec3a9 --- /dev/null +++ b/applications/shipment-tracker/src/config.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs'; +import type { DataSourceOptions } from 'typeorm'; +import { AccountSchema, ShipmentSchema, EventSchema } from './entities.js'; +import { Initial1700000000000 } from '../migrations/initial.js'; + +export function required(name: string): string { + const value = process.env[name]; + if (!value?.trim()) throw new Error(`Missing configuration: ${name}`); + return value; +} +export function dbOptions(migration = false): DataSourceOptions { + const role = migration ? 'shipments_migrator' : 'shipments_app'; + if (required('PGUSER') !== role) throw new Error(`This command requires ${role}`); + const port = Number(process.env.PGPORT ?? 5432); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid Postgres port'); + return { + type: 'postgres', host: required('PGHOST'), port, database: required('PGDATABASE'), + username: role, password: required('PGPASSWORD'), schema: 'shipments', + ssl: { ca: readFileSync(required('PGSSLROOTCERT'), 'utf8'), rejectUnauthorized: true }, + synchronize: false, migrationsRun: false, logging: false, + entities: [AccountSchema, ShipmentSchema, EventSchema], migrations: [Initial1700000000000], + migrationsTableName: 'schema_migrations', migrationsTransactionMode: 'all', + extra: { + max: 5, connectionTimeoutMillis: 10_000, idleTimeoutMillis: 30_000, + query_timeout: 25_000, + options: '-c search_path=shipments -c timezone=UTC -c statement_timeout=20000' + + (migration ? ' -c role=shipments_owner' : ''), + }, + }; +} +export function analyticalUrl(value: string): string { + const url = new URL(value); + if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || + url.search || url.hash || !['', '/'].includes(url.pathname)) throw new Error('Invalid analytical HTTPS URL'); + return value; +} diff --git a/applications/shipment-tracker/src/domain.ts b/applications/shipment-tracker/src/domain.ts new file mode 100644 index 00000000..fe2f46cd --- /dev/null +++ b/applications/shipment-tracker/src/domain.ts @@ -0,0 +1,32 @@ +import { BadRequestException, ConflictException, ServiceUnavailableException } from '@nestjs/common'; +import type { ShipmentStatus } from './entities.js'; + +export function assertTransition(from: ShipmentStatus, to: ShipmentStatus): void { + if (!((from === 'created' && (to === 'dispatched' || to === 'cancelled')) || + (from === 'dispatched' && (to === 'delivered' || to === 'cancelled')))) { + throw new ConflictException('Transition is not allowed from the current state'); + } +} +export function deliveredDuration(eventAt: Date, dispatchedAt: Date | null): string { + if (!dispatchedAt) throw new ServiceUnavailableException('Missing dispatch fact'); + const duration = eventAt.getTime() - dispatchedAt.getTime(); + if (!Number.isSafeInteger(duration) || duration < 0) { + throw new ServiceUnavailableException('Server clock precedes dispatch'); + } + return String(duration); +} +export function reportRange(from?: string, to?: string, now = new Date()): [string, string] { + const today = now.toISOString().slice(0, 10); + const earliest = new Date(Date.parse(today) - 30 * 86_400_000).toISOString().slice(0, 10); + const start = from ?? new Date(Date.parse(today) - 6 * 86_400_000).toISOString().slice(0, 10); + const end = to ?? today; + for (const day of [start, end]) { + const parsed = new Date(day); + if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(parsed.getTime()) || + parsed.toISOString().slice(0, 10) !== day) throw new BadRequestException('Invalid report date'); + } + if (start > end || start < earliest || end > today) { + throw new BadRequestException('Reports cover at most the last 31 UTC days'); + } + return [start, end]; +} diff --git a/applications/shipment-tracker/src/dto.ts b/applications/shipment-tracker/src/dto.ts new file mode 100644 index 00000000..daaae35d --- /dev/null +++ b/applications/shipment-tracker/src/dto.ts @@ -0,0 +1,29 @@ +import { Transform } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, IsUUID, Matches, Max, Min } from 'class-validator'; + +export class TransitionDto { + @IsString() @Matches(/^[A-Za-z0-9._-]{1,64}$(?![\s\S])/u) + requestId!: string; + @IsInt() @Min(0) @Max(9_999) + expectedRevision!: number; + @IsIn(['dispatched', 'delivered', 'cancelled']) + toStatus!: 'dispatched' | 'delivered' | 'cancelled'; +} +export class ListDto { + @IsOptional() @IsUUID('4') after?: string; + @Transform(({ value }) => typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value) + @IsInt() @Min(1) @Max(50) + limit = 20; +} +export class HistoryDto { + @Transform(({ value }) => typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value) + @IsInt() @Min(0) @Max(10_000) + afterRevision = 0; + @Transform(({ value }) => typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value) + @IsInt() @Min(1) @Max(50) + limit = 20; +} +export class ReportDto { + @IsOptional() @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) from?: string; + @IsOptional() @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) to?: string; +} diff --git a/applications/shipment-tracker/src/entities.ts b/applications/shipment-tracker/src/entities.ts new file mode 100644 index 00000000..c637a972 --- /dev/null +++ b/applications/shipment-tracker/src/entities.ts @@ -0,0 +1,50 @@ +import { EntitySchema } from 'typeorm'; + +export type ShipmentStatus = 'created' | 'dispatched' | 'delivered' | 'cancelled'; +export type ServiceLevel = 'standard' | 'express'; +export interface Account { id: string; name: string } +export interface Shipment { + id: string; accountId: string; serviceLevel: ServiceLevel; + status: ShipmentStatus; revision: number; + dispatchedAt: Date | null; createdAt: Date; updatedAt: Date; +} +export interface TransitionEvent { + id: string; accountId: string; shipmentId: string; requestId: string; + expectedRevision: number; revision: number; + fromStatus: ShipmentStatus; toStatus: ShipmentStatus; serviceLevel: ServiceLevel; + eventAt: Date; eventDay: string; dispatchedAt: Date | null; durationMs: string; +} +export const AccountSchema = new EntitySchema({ + name: 'Account', schema: 'shipments', tableName: 'accounts', + columns: { id: { type: 'uuid', primary: true, name: 'account_id' }, name: { type: 'text' } }, +}); +export const ShipmentSchema = new EntitySchema({ + name: 'Shipment', schema: 'shipments', tableName: 'shipments', + columns: { + id: { type: 'uuid', primary: true, name: 'shipment_id' }, + accountId: { type: 'uuid', name: 'account_id' }, + serviceLevel: { type: 'text', name: 'service_level' }, + status: { type: 'text' }, revision: { type: 'integer' }, + dispatchedAt: { type: 'timestamptz', precision: 3, nullable: true, name: 'dispatched_at' }, + createdAt: { type: 'timestamptz', precision: 3, name: 'created_at' }, + updatedAt: { type: 'timestamptz', precision: 3, name: 'updated_at' }, + }, +}); +export const EventSchema = new EntitySchema({ + name: 'TransitionEvent', schema: 'shipments', tableName: 'events', + columns: { + id: { type: 'uuid', primary: true, name: 'event_id' }, + accountId: { type: 'uuid', name: 'account_id' }, + shipmentId: { type: 'uuid', name: 'shipment_id' }, + requestId: { type: 'text', name: 'request_id' }, + expectedRevision: { type: 'integer', name: 'expected_revision' }, + revision: { type: 'integer' }, + fromStatus: { type: 'text', name: 'from_status' }, + toStatus: { type: 'text', name: 'to_status' }, + serviceLevel: { type: 'text', name: 'service_level' }, + eventAt: { type: 'timestamptz', precision: 3, name: 'event_at' }, + eventDay: { type: 'date', name: 'event_day' }, + dispatchedAt: { type: 'timestamptz', precision: 3, nullable: true, name: 'dispatched_at' }, + durationMs: { type: 'bigint', name: 'duration_ms' }, + }, +}); diff --git a/applications/shipment-tracker/src/main.ts b/applications/shipment-tracker/src/main.ts new file mode 100644 index 00000000..d2814b3d --- /dev/null +++ b/applications/shipment-tracker/src/main.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import type { Response } from 'express'; +import { AppModule } from './app.js'; + +@Catch() +class PublicErrors implements ExceptionFilter { + catch(error: unknown, host: ArgumentsHost): void { + const parser = error as { status?: number; type?: string }; + const status = error instanceof HttpException ? error.getStatus() : + parser?.status === 413 ? 413 : + parser?.status === 400 && parser.type === 'entity.parse.failed' ? 400 : 503; + const message = status === 503 ? 'Service unavailable; shipment state is authoritative in Postgres' : + status === 413 ? 'Request body exceeds 4 KiB' : + error instanceof HttpException ? error.message : 'Invalid request'; + host.switchToHttp().getResponse().status(status).json({ error: message }); + } +} +async function main(): Promise { + const port = Number(process.env.PORT ?? 3000); + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid port'); + const app = await NestFactory.create(AppModule, { logger: false, abortOnError: false }); + app.useBodyParser('json', { limit: '4kb' }); + app.useGlobalPipes(new ValidationPipe({ + transform: true, whitelist: true, forbidNonWhitelisted: true, + validationError: { target: false, value: false }, + })); + app.useGlobalFilters(new PublicErrors()); + app.enableShutdownHooks(); + await app.listen(port, '127.0.0.1'); + console.log('Shipment API ready on loopback'); +} +main().catch(() => { console.error('Shipment API startup failed; check configuration, TLS, roles and database'); process.exitCode = 1; }); diff --git a/applications/shipment-tracker/src/reports.ts b/applications/shipment-tracker/src/reports.ts new file mode 100644 index 00000000..43638716 --- /dev/null +++ b/applications/shipment-tracker/src/reports.ts @@ -0,0 +1,56 @@ +import { Injectable, OnApplicationShutdown } from '@nestjs/common'; +import { createClient } from '@clickhouse/client'; +import { analyticalUrl, required } from './config.js'; +import { reportRange } from './domain.js'; +import type { ReportDto } from './dto.js'; + +const REPORT_LIMITS = { + max_execution_time: 5, timeout_before_checking_execution_speed: 0, + max_rows_to_read: '1000000', max_bytes_to_read: '100000000', + max_result_rows: '186', result_overflow_mode: 'throw' as const, + output_format_json_quote_64bit_integers: 1 as const, +}; +interface ReportRow { + eventDay: string; serviceLevel: string; toStatus: string; + events: string; deliveredCount: string; totalDurationMs: string; maxDurationMs: string; +} +@Injectable() +export class ReportsService implements OnApplicationShutdown { + // One shared pool. Client settings are defaults; individual requests can override them. + private readonly client = createClient({ + url: analyticalUrl(required('CLICKHOUSE_URL')), + username: required('CLICKHOUSE_USER'), password: required('CLICKHOUSE_PASSWORD'), + database: 'default', application: 'shipment-tracker', + max_open_connections: 2, request_timeout: 10_000, + clickhouse_settings: REPORT_LIMITS, + }); + constructor() { + if (required('CLICKHOUSE_USER') !== 'shipments_reports') throw new Error('Restricted analytical login required'); + } + async report(accountId: string, input: ReportDto) { + const [from, to] = reportRange(input.from, input.to); + const result = await this.client.query({ + query: ` + SELECT event_day AS eventDay, service_level AS serviceLevel, to_status AS toStatus, + toString(count()) AS events, + toString(countIf(to_status = 'delivered')) AS deliveredCount, + toString(sumIf(duration_ms, to_status = 'delivered')) AS totalDurationMs, + toString(maxIf(duration_ms, to_status = 'delivered')) AS maxDurationMs + FROM default.cdc_shipment_events FINAL + WHERE account_id = {account:UUID} + AND event_day BETWEEN {from:Date32} AND {to:Date32} + AND _peerdb_is_deleted = 0 + GROUP BY event_day, service_level, to_status + ORDER BY event_day, service_level, to_status + LIMIT 186 + `, + query_params: { account: accountId, from, to }, format: 'JSONEachRow', + clickhouse_settings: REPORT_LIMITS, abort_signal: AbortSignal.timeout(15_000), + }); + try { + const rows = await result.json(); + return { accountId, from, to, consistency: 'eventual', operationalAuthority: 'postgres', rows }; + } finally { result.close(); } + } + async onApplicationShutdown(): Promise { await this.client.close(); } +} diff --git a/applications/shipment-tracker/src/shipments.ts b/applications/shipment-tracker/src/shipments.ts new file mode 100644 index 00000000..4b0c8839 --- /dev/null +++ b/applications/shipment-tracker/src/shipments.ts @@ -0,0 +1,75 @@ +import { ConflictException, Injectable, NotFoundException, OnModuleInit } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { randomUUID } from 'node:crypto'; +import { DataSource } from 'typeorm'; +import { AccountGuard } from './auth.js'; +import type { TransitionDto, ListDto, HistoryDto } from './dto.js'; +import { AccountSchema, EventSchema, ShipmentSchema } from './entities.js'; +import { assertTransition, deliveredDuration } from './domain.js'; + +@Injectable() +export class ShipmentsService implements OnModuleInit { + constructor(@InjectDataSource() private readonly source: DataSource, private readonly guard: AccountGuard) {} + async onModuleInit(): Promise { + for (const id of this.guard.scopes.accountIds) { + if (!await this.source.getRepository(AccountSchema).existsBy({ id })) throw new Error('Configured account missing'); + } + } + async get(accountId: string, id: string) { + const shipment = await this.source.getRepository(ShipmentSchema).findOneBy({ accountId, id }); + if (!shipment) throw new NotFoundException('Shipment not found'); + return shipment; + } + async list(accountId: string, input: ListDto) { + const query = this.source.getRepository(ShipmentSchema).createQueryBuilder('shipment') + .where('shipment.accountId = :accountId', { accountId }) + .orderBy('shipment.id', 'ASC').take(input.limit + 1); + if (input.after) query.andWhere('shipment.id > :after', { after: input.after }); + const rows = await query.getMany(); + const items = rows.slice(0, input.limit); + return { items, nextAfter: rows.length > input.limit ? items.at(-1)!.id : null, consistency: 'live' }; + } + async history(accountId: string, id: string, input: HistoryDto) { + await this.get(accountId, id); + const rows = await this.source.getRepository(EventSchema).createQueryBuilder('event') + .where('event.accountId = :accountId AND event.shipmentId = :id AND event.revision > :after', { + accountId, id, after: input.afterRevision, + }).orderBy('event.revision', 'ASC').take(input.limit + 1).getMany(); + const items = rows.slice(0, input.limit); + return { items, nextRevision: rows.length > input.limit ? items.at(-1)!.revision : null }; + } + async transition(accountId: string, id: string, input: TransitionDto) { + // Every repository in this callback belongs to this transaction's manager. + return this.source.transaction('READ COMMITTED', async manager => { + await manager.getRepository(AccountSchema).createQueryBuilder('account') + .where('account.id = :accountId', { accountId }).setLock('pessimistic_write').getOneOrFail(); + // Account lock also serializes same request IDs across different shipments. + const retained = await manager.getRepository(EventSchema).findOneBy({ accountId, requestId: input.requestId }); + if (retained) { + if (retained.shipmentId !== id || retained.expectedRevision !== input.expectedRevision || + retained.toStatus !== input.toStatus) throw new ConflictException('Request ID already has different data'); + return { created: false, event: retained }; + } + const shipment = await manager.getRepository(ShipmentSchema).createQueryBuilder('shipment') + .where('shipment.accountId = :accountId AND shipment.id = :id', { accountId, id }) + .setLock('pessimistic_write').getOne(); + if (!shipment) throw new NotFoundException('Shipment not found'); + if (shipment.revision !== input.expectedRevision) throw new ConflictException('Shipment revision changed; refetch before retrying'); + assertTransition(shipment.status, input.toStatus); + const eventAt = new Date(); + const dispatchedAt = input.toStatus === 'dispatched' ? eventAt : shipment.dispatchedAt; + const event = manager.getRepository(EventSchema).create({ + id: randomUUID(), accountId, shipmentId: id, requestId: input.requestId, + expectedRevision: input.expectedRevision, revision: shipment.revision + 1, + fromStatus: shipment.status, toStatus: input.toStatus, serviceLevel: shipment.serviceLevel, + eventAt, eventDay: eventAt.toISOString().slice(0, 10), dispatchedAt, + durationMs: input.toStatus === 'delivered' ? deliveredDuration(eventAt, dispatchedAt) : '0', + }); + await manager.getRepository(ShipmentSchema).update({ accountId, id }, { + status: input.toStatus, revision: event.revision, dispatchedAt, updatedAt: eventAt, + }); + await manager.getRepository(EventSchema).insert(event); + return { created: true, event }; + }); + } +} diff --git a/applications/shipment-tracker/test/cloud-check.mjs b/applications/shipment-tracker/test/cloud-check.mjs new file mode 100644 index 00000000..30b6e215 --- /dev/null +++ b/applications/shipment-tracker/test/cloud-check.mjs @@ -0,0 +1,139 @@ +// Run manually against the seeded Cloud fixture; never runs in credential-free CI. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { setTimeout as pause } from 'node:timers/promises'; +import pg from 'pg'; +const accountA = '00000000-0000-4000-8000-000000000001'; +const accountB = '00000000-0000-4000-8000-000000000002'; +const tokens = JSON.parse(process.env.ACCOUNT_TOKENS); +const base = process.env.TEST_URL ?? 'http://127.0.0.1:3000'; +const ship = (account, n) => `${account === accountA ? 'a' : 'b'}0000000-0000-4000-8000-${String(n).padStart(12, '0')}`; +async function http(account, path, body, expected = 200) { + const response = await fetch(base + path, { + method: body ? 'POST' : 'GET', + headers: { Authorization: `Bearer ${tokens[account]}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, + body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(35_000), + }); + const data = await response.json(); + if (expected !== null) assert.equal(response.status, expected, JSON.stringify(data)); + return { status: response.status, data }; +} +const transition = (account, n, requestId, expectedRevision, toStatus, expected = 201) => + http(account, `/shipments/${ship(account, n)}/transitions`, { requestId, expectedRevision, toStatus }, expected); +const pool = new pg.Pool({ + host: process.env.PGHOST, port: Number(process.env.PGPORT), database: process.env.PGDATABASE, + user: 'shipments_migrator', password: process.env.TEST_MIGRATOR_PASSWORD, + ssl: { ca: readFileSync(process.env.PGSSLROOTCERT, 'utf8'), rejectUnauthorized: true }, + max: 2, connectionTimeoutMillis: 10_000, query_timeout: 25_000, + options: '-c role=shipments_owner -c timezone=UTC -c statement_timeout=20000', +}); +async function snapshot() { + await transition(accountA, 1, 'snapshot-a-dispatch', 0, 'dispatched'); + await pause(30); + const delivered = (await transition(accountA, 1, 'snapshot-a-deliver', 1, 'delivered')).data; + assert.equal(BigInt(delivered.durationMs), BigInt(Date.parse(delivered.eventAt) - Date.parse(delivered.dispatchedAt))); + await transition(accountB, 1, 'snapshot-b-dispatch', 0, 'dispatched'); + const { rows } = await pool.query('SELECT count(*)::int AS count FROM shipments.events'); + assert.equal(rows[0].count, 3); + console.log('Initial snapshot fixture: 3 accepted events, terminal duration matches stored millisecond timestamps'); +} +async function core() { + const race = await Promise.all([ + transition(accountA, 2, 'race-dispatch', 0, 'dispatched', null), + transition(accountA, 2, 'race-cancel', 0, 'cancelled', null), + ]); + assert.deepEqual(race.map(r => r.status).sort(), [201, 409]); + assert.equal((await http(accountA, `/shipments/${ship(accountA, 2)}`)).data.revision, 1); + assert.equal((await http(accountA, `/shipments/${ship(accountA, 2)}/events`)).data.items.length, 1); + console.log('Two simultaneous transitions from revision 0: one 201, one 409, one state/event advance'); + const duplicates = await Promise.all(Array.from({ length: 10 }, () => transition(accountA, 3, 'same-id', 0, 'dispatched', null))); + assert.deepEqual(duplicates.map(r => r.status).sort(), [...Array(9).fill(200), 201]); + assert.equal(new Set(duplicates.map(r => r.data.id)).size, 1); + assert.equal((await http(accountA, `/shipments/${ship(accountA, 3)}/events`)).data.items.length, 1); + await pause(30); + await transition(accountA, 3, 'same-id-deliver', 1, 'delivered'); + const retry = await transition(accountA, 3, 'same-id', 0, 'dispatched', 200); + assert.deepEqual(retry.data, duplicates[0].data); + const uppercase = await http(accountA, `/shipments/${ship(accountA, 3).toUpperCase()}/transitions`, + { requestId: 'same-id', expectedRevision: 0, toStatus: 'dispatched' }); + assert.deepEqual(uppercase.data, retry.data); + await transition(accountA, 3, 'same-id', 0, 'cancelled', 409); + await transition(accountA, 3, 'same-id', 1, 'dispatched', 409); + await transition(accountA, 4, 'same-id', 0, 'dispatched', 409); + console.log('10 simultaneous matching requests: one 201 + nine 200; retained replay survives later delivery, uppercase UUID path, and restart fixture; changed payload/revision/shipment conflicts'); + await transition(accountA, 4, 'cancel-created', 0, 'cancelled'); + await transition(accountA, 4, 'invalid-terminal', 1, 'dispatched', 409); + await transition(accountA, 6, 'skip-dispatch', 0, 'delivered', 409); + await http(accountB, `/shipments/${ship(accountA, 1)}`, undefined, 404); + await http(accountB, `/shipments/${ship(accountA, 1)}/events`, undefined, 404); + await http(accountB, `/shipments/${ship(accountA, 1)}/transitions`, { requestId: 'cross-account', expectedRevision: 2, toStatus: 'cancelled' }, 404); + // Trusted account identity cannot be supplied by a caller. + await http(accountA, `/shipments/${ship(accountA, 6)}/transitions`, + { requestId: 'forged', expectedRevision: 0, toStatus: 'dispatched', accountId: accountB }, 400); + for (const body of [ + { requestId: 'newline\n', expectedRevision: 0, toStatus: 'dispatched' }, + { requestId: 'nul\0', expectedRevision: 0, toStatus: 'dispatched' }, + { requestId: 'string-revision', expectedRevision: '0', toStatus: 'dispatched' }, + { requestId: 'negative', expectedRevision: -1, toStatus: 'dispatched' }, + ]) await http(accountA, `/shipments/${ship(accountA, 6)}/transitions`, body, 400); + for (const path of ['/shipments?limit=51', '/shipments?after=bad', '/shipments/not-a-uuid', '/reports?from=2026-02-30', '/reports?from=1900-01-01']) { + await http(accountA, path, undefined, 400); + } + await http(accountA, `/shipments/${ship(accountA, 6)}/transitions`, { requestId: 'oversized', padding: 'x'.repeat(5000) }, 413); + const unauthenticated = await fetch(base + '/shipments', { headers: { Authorization: 'Bearer invalid' } }); + assert.equal(unauthenticated.status, 401); + const listed = []; + let after = ''; + do { + const page = (await http(accountA, `/shipments?limit=2${after ? `&after=${after}` : ''}`)).data; + assert.ok(page.items.length <= 2); + listed.push(...page.items.map(s => s.id)); + after = page.nextAfter; + } while (after); + assert.deepEqual(listed, Array.from({ length: 6 }, (_, i) => ship(accountA, i + 1))); + const firstHistory = (await http(accountA, `/shipments/${ship(accountA, 3)}/events?limit=1`)).data; + assert.equal(firstHistory.nextRevision, 1); + const secondHistory = (await http(accountA, `/shipments/${ship(accountA, 3)}/events?limit=1&afterRevision=1`)).data; + assert.equal(secondHistory.items[0].revision, 2); + assert.equal(secondHistory.nextRevision, null); + console.log('Lifecycle, stale revisions, account isolation, authentication, strict inputs, 4 KiB body, UUID keyset and revision history controls passed'); + const before = (await http(accountA, `/shipments/${ship(accountA, 5)}`)).data; + await pool.query(`CREATE FUNCTION shipments.reject_fixture_event() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.request_id = 'force-rollback' THEN RAISE EXCEPTION 'Controlled acceptance insert failure'; END IF; RETURN NEW; END $$; + CREATE TRIGGER reject_fixture_event BEFORE INSERT ON shipments.events FOR EACH ROW EXECUTE FUNCTION shipments.reject_fixture_event()`); + try { + await transition(accountA, 5, 'force-rollback', 0, 'dispatched', 503); + assert.deepEqual((await http(accountA, `/shipments/${ship(accountA, 5)}`)).data, before); + assert.equal((await http(accountA, `/shipments/${ship(accountA, 5)}/events`)).data.items.length, 0); + console.log('Forced failure after shipment UPDATE: state, revision, dispatch timestamp and event insertion all rolled back'); + } finally { + await pool.query('DROP TRIGGER reject_fixture_event ON shipments.events; DROP FUNCTION shipments.reject_fixture_event()'); + } +} +async function equality() { + for (const account of [accountA, accountB]) { + const { rows } = await pool.query(`SELECT event_day::text AS "eventDay", service_level AS "serviceLevel", to_status AS "toStatus", + count(*)::text AS events, count(*) FILTER (WHERE to_status='delivered')::text AS "deliveredCount", + coalesce(sum(duration_ms) FILTER (WHERE to_status='delivered'),0)::text AS "totalDurationMs", + coalesce(max(duration_ms) FILTER (WHERE to_status='delivered'),0)::text AS "maxDurationMs" + FROM shipments.events WHERE account_id=$1 AND event_day BETWEEN (current_timestamp AT TIME ZONE 'UTC')::date-6 AND (current_timestamp AT TIME ZONE 'UTC')::date + GROUP BY event_day,service_level,to_status ORDER BY event_day,service_level,to_status`, [account]); + let last; + const deadline = Date.now() + 180_000; + do { + last = await http(account, '/reports', undefined, null); + if (last.status === 200 && JSON.stringify(last.data.rows) === JSON.stringify(rows)) break; + await pause(3000); + } while (Date.now() < deadline); + assert.equal(last.status, 200); + assert.deepEqual(last.data.rows, rows); + for (const row of last.data.rows) for (const key of ['events', 'deliveredCount', 'totalDurationMs', 'maxDurationMs']) assert.equal(typeof row[key], 'string'); + console.log(`Exact account ${account.at(-1)} equality: ${rows.length} daily groups; counts, delivered count, total/max duration; all aggregates are strings`); + } +} +try { + if (process.argv[2] === 'snapshot') await snapshot(); + else if (process.argv[2] === 'core') await core(); + else if (process.argv[2] === 'equality') await equality(); + else throw new Error('Use snapshot, core or equality'); +} finally { await pool.end(); } diff --git a/applications/shipment-tracker/test/pipeline-check.mjs b/applications/shipment-tracker/test/pipeline-check.mjs new file mode 100644 index 00000000..d12c90ab --- /dev/null +++ b/applications/shipment-tracker/test/pipeline-check.mjs @@ -0,0 +1,117 @@ +// Requires private administrator test credentials. The application has neither. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { setTimeout as pause } from 'node:timers/promises'; +import { createClient } from '@clickhouse/client'; +import pg from 'pg'; +const a = '00000000-0000-4000-8000-000000000001'; +const b = '00000000-0000-4000-8000-000000000002'; +const tokens = JSON.parse(process.env.ACCOUNT_TOKENS); +const admin = createClient({ url: process.env.CLICKHOUSE_URL, username: 'default', + password: process.env.TEST_CH_ADMIN_PASSWORD, request_timeout: 10_000, + clickhouse_settings: { max_execution_time: 5, max_rows_to_read: '1000000', max_bytes_to_read: '100000000' } }); +const owner = new pg.Pool({ host: process.env.PGHOST, port: Number(process.env.PGPORT), database: process.env.PGDATABASE, + user: 'shipments_migrator', password: process.env.TEST_MIGRATOR_PASSWORD, + ssl: { ca: readFileSync(process.env.PGSSLROOTCERT, 'utf8'), rejectUnauthorized: true }, + options: '-c role=shipments_owner -c timezone=UTC -c statement_timeout=20000', connectionTimeoutMillis: 10_000 }); +async function http(account, path, body, expected = 200) { + const response = await fetch('http://127.0.0.1:3000' + path, { method: body ? 'POST' : 'GET', + headers: { Authorization: `Bearer ${tokens[account]}`, 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(35_000) }); + const data = await response.json(); + assert.equal(response.status, expected, JSON.stringify(data)); + return data; +} +async function lag() { + const before = await http(b, '/reports'); + await http(b, '/shipments/b0000000-0000-4000-8000-000000000002/transitions', + { requestId: 'paused-dispatch', expectedRevision: 0, toStatus: 'dispatched' }, 201); + assert.equal((await http(b, '/shipments/b0000000-0000-4000-8000-000000000002')).revision, 1); + const after = await http(b, '/reports'); + assert.deepEqual(after.rows, before.rows); + console.log('Confirmed Paused pipeline: PG accepted dispatch/current revision 1 while analytical report retained its older exact groups'); +} +async function outage() { + await admin.command({ query: 'REVOKE SELECT ON default.cdc_shipment_events FROM shipments_reports' }); + try { + await Promise.all([http(a, '/reports', undefined, 503), http(b, '/reports', undefined, 503)]); + await http(b, '/shipments/b0000000-0000-4000-8000-000000000003/transitions', + { requestId: 'analytics-outage-dispatch', expectedRevision: 0, toStatus: 'dispatched' }, 201); + assert.equal((await http(b, '/shipments/b0000000-0000-4000-8000-000000000003')).revision, 1); + console.log('Both analytical capacity slots returned bounded 503 during SELECT outage; independent PG transition returned 201 and current revision 1'); + } finally { await admin.command({ query: 'GRANT SELECT ON default.cdc_shipment_events TO shipments_reports' }); } + await Promise.all([http(a, '/reports'), http(b, '/reports')]); + console.log('Both report slots recovered to 200 on the same server/client after table SELECT restoration'); +} +async function reader() { + const client = createClient({ url: process.env.CLICKHOUSE_URL, username: process.env.CLICKHOUSE_USER, + password: process.env.CLICKHOUSE_PASSWORD, request_timeout: 10_000 }); + try { + for (const query of ['CREATE TABLE default.forbidden(x Int32) ENGINE=Memory', + 'INSERT INTO default.cdc_shipment_events(event_id) VALUES(generateUUIDv4())', 'SELECT name FROM system.users LIMIT 1']) { + await assert.rejects(client.command({ query }), error => ['164', '497'].includes(error.code)); + console.log('Analytical reader rejected:', query.split(' ').slice(0, 4).join(' ')); + } + } finally { await client.close(); } + const wrong = createClient({ url: process.env.CLICKHOUSE_URL, username: process.env.CLICKHOUSE_USER, + password: process.env.CLICKHOUSE_PASSWORD, tls: { ca_cert: readFileSync(process.env.TEST_WRONG_CA) }, request_timeout: 10_000 }); + try { await assert.rejects(wrong.command({ query: 'SELECT 1' }), error => { + console.log('Untrusted analytical CA TLS code:', error.code); + return ['SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'].includes(error.code); + }); + console.log('Analytical HTTPS connection rejected an independent untrusted CA'); + } finally { await wrong.close(); } +} +async function tombstone() { + // Controlled privileged maintenance only. Runtime UPDATE/DELETE were proven denied. + const { rows } = await owner.query("DELETE FROM shipments.events WHERE account_id=$1 AND request_id='cancel-created' RETURNING *, event_day::text AS probe_day", [a]); + assert.equal(rows.length, 1); + const event = rows[0]; + try { + const deadline = Date.now() + 180_000; + let observed = false; + // PostgreSQL DATE is a calendar value; avoid a local-midnight Date -> UTC conversion. + const day = event.probe_day; + console.log('Delete probe identity:', JSON.stringify({ account: a, day, event: event.event_id, pgDateType: typeof event.event_day })); + let attempts = 0; + do { + const result = await admin.query({ query: `SELECT account_id,event_day,event_id,toUInt8(_peerdb_is_deleted) AS deleted,toString(_peerdb_version) AS version FROM default.cdc_shipment_events FINAL + WHERE account_id={account:UUID} AND event_day={day:Date32} AND event_id={event:UUID} LIMIT 1`, + query_params: { account: a, day, event: event.event_id }, format: 'JSONEachRow' }); + try { + const versions = await result.json(); + if (attempts++ < 3 || versions[0]?.deleted === 1) console.log('FINAL probe:', JSON.stringify(versions)); + observed = versions[0]?.deleted === 1; + } finally { result.close(); } + if (observed) break; + await pause(3000); + } while (Date.now() < deadline); + assert.equal(observed, true); + const report = await http(a, '/reports'); + const expected = await owner.query(`SELECT event_day::text AS "eventDay", service_level AS "serviceLevel", to_status AS "toStatus", + count(*)::text AS events, count(*) FILTER (WHERE to_status='delivered')::text AS "deliveredCount", + coalesce(sum(duration_ms) FILTER (WHERE to_status='delivered'),0)::text AS "totalDurationMs", + coalesce(max(duration_ms) FILTER (WHERE to_status='delivered'),0)::text AS "maxDurationMs" + FROM shipments.events WHERE account_id=$1 AND event_day BETWEEN (current_timestamp AT TIME ZONE 'UTC')::date-6 AND (current_timestamp AT TIME ZONE 'UTC')::date + GROUP BY event_day,service_level,to_status ORDER BY event_day,service_level,to_status`, [a]); + assert.deepEqual(report.rows, expected.rows); + const count = await admin.query({ query: `SELECT toString(count()) AS n FROM default.cdc_shipment_events FINAL + WHERE account_id={account:UUID} AND event_id={event:UUID} AND _peerdb_is_deleted=0`, + query_params: { account: a, event: event.event_id }, format: 'JSONEachRow' }); + try { assert.equal((await count.json())[0].n, '0'); } finally { count.close(); } + console.log('Privileged fixture DELETE produced a FINAL tombstone; live-row filter excludes it and all deleted-state report aggregates exactly equal current PG'); + } finally { + const columns = ['event_id','account_id','shipment_id','request_id','expected_revision','revision','from_status','to_status', + 'service_level','event_at','event_day','dispatched_at','duration_ms']; + await owner.query(`INSERT INTO shipments.events(${columns.join(',')}) VALUES(${columns.map((_, i) => `$${i + 1}`).join(',')})`, columns.map(c => event[c])); + console.log('Restored exact immutable fixture event/identity through privileged test role; final equality check must observe the newer live version'); + } +} +try { + const mode = process.argv[2]; + if (mode === 'lag') await lag(); + else if (mode === 'outage') await outage(); + else if (mode === 'reader') await reader(); + else if (mode === 'tombstone') await tombstone(); + else throw new Error('Use lag, outage, reader or tombstone'); +} finally { await Promise.all([admin.close(), owner.end()]); } diff --git a/applications/shipment-tracker/test/process-restart.mjs b/applications/shipment-tracker/test/process-restart.mjs new file mode 100644 index 00000000..c92682b8 --- /dev/null +++ b/applications/shipment-tracker/test/process-restart.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { setTimeout as pause } from 'node:timers/promises'; +const account = '00000000-0000-4000-8000-000000000001'; +const token = JSON.parse(process.env.ACCOUNT_TOKENS)[account]; +const base = 'http://127.0.0.1:3001'; +const path = '/shipments/A0000000-0000-4000-8000-000000000003/transitions'; +async function request() { + const response = await fetch(base + path, { method: 'POST', headers: { + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', + }, body: JSON.stringify({ requestId: 'same-id', expectedRevision: 0, toStatus: 'dispatched' }), + signal: AbortSignal.timeout(35_000) }); + assert.equal(response.status, 200); + return response.json(); +} +async function ready(child) { + for (let attempt = 0; attempt < 60; attempt++) { + assert.equal(child.exitCode, null, 'Server exited before becoming ready'); + try { + const response = await fetch(base + '/shipments', { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(2000) }); + if (response.status === 200) { + const keys = readFileSync(`/proc/${child.pid}/environ`, 'utf8').split('\0').map(e => e.split('=')[0]); + assert.ok(!keys.some(k => /MIGRATOR|CDC_PASSWORD|ADMIN|CLICKHOUSE_API|TEST_/.test(k)), 'Setup credentials leaked to runtime child'); + return; + } + } catch { /* Bounded readiness polling before the new listener is ready. */ } + await pause(1000); + } + throw new Error('Server readiness timeout'); +} +async function stop(child) { + child.kill('SIGTERM'); + for (let attempt = 0; attempt < 50 && child.exitCode === null && child.signalCode === null; attempt++) await pause(100); + assert.ok(child.exitCode !== null || child.signalCode !== null, 'Original process must exit before replacement is started'); + assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' }); +} +const launch = () => spawn('bash', ['scripts/start-runtime.sh'], { + env: { ...process.env, PORT: '3001' }, stdio: ['ignore', 'ignore', 'inherit'], +}); +let child = launch(); +try { + await ready(child); + const first = await request(); + const firstPid = child.pid; + await stop(child); + child = launch(); + await ready(child); + assert.notEqual(child.pid, firstPid); + assert.deepEqual(await request(), first); + const current = await fetch(base + '/shipments/a0000000-0000-4000-8000-000000000003', { + headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(35_000), + }); + assert.equal(current.status, 200); + assert.equal((await current.json()).revision, 2); + const malformed = await fetch(base + path, { method: 'POST', headers: { + Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', + }, body: '{bad-json', signal: AbortSignal.timeout(35_000) }); + assert.equal(malformed.status, 400); + console.log(`Runtime-only external process restart ${firstPid} -> ${child.pid}: first PID confirmed gone; uppercase retained retry returns identical 200 and current revision 2; malformed JSON returns 400`); +} finally { if (child.exitCode === null && child.signalCode === null) await stop(child); } diff --git a/applications/shipment-tracker/test/security-check.mjs b/applications/shipment-tracker/test/security-check.mjs new file mode 100644 index 00000000..98e4881c --- /dev/null +++ b/applications/shipment-tracker/test/security-check.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import pg from 'pg'; +const config = { + host: process.env.PGHOST, port: Number(process.env.PGPORT), database: process.env.PGDATABASE, + ssl: { ca: readFileSync(process.env.PGSSLROOTCERT, 'utf8'), rejectUnauthorized: true }, + connectionTimeoutMillis: 10_000, query_timeout: 20_000, +}; +const app = new pg.Pool({ ...config, user: 'shipments_app', password: process.env.PGPASSWORD }); +const cdc = new pg.Pool({ ...config, user: 'shipments_cdc', password: process.env.TEST_CDC_PASSWORD }); +async function rejected(pool, sql, code, values) { + await assert.rejects(pool.query(sql, values), error => error.code === code); + console.log(`Rejected ${code}: ${sql.split(' ').slice(0, 4).join(' ')}`); +} +try { + const tls = (await app.query('SELECT ssl, version FROM pg_stat_ssl WHERE pid=pg_backend_pid()')).rows[0]; + assert.equal(tls.ssl, true); + console.log('Verified Cloud CA + hostname connection:', tls); + for (const sql of [ + 'UPDATE shipments.events SET duration_ms=0', 'DELETE FROM shipments.events', + 'CREATE TABLE shipments.forbidden(id int)', 'INSERT INTO shipments.shipments(shipment_id) VALUES(NULL)', + ]) await rejected(app, sql, '42501'); + await rejected(cdc, 'SELECT * FROM shipments.shipments LIMIT 1', '42501'); + const insert = `INSERT INTO shipments.events(event_id,account_id,shipment_id,request_id,expected_revision,revision, + from_status,to_status,service_level,event_at,event_day,dispatched_at,duration_ms) + VALUES($1,$2,$3,$4,0,1,'created','dispatched','standard',date_trunc('milliseconds',current_timestamp), + (current_timestamp AT TIME ZONE 'UTC')::date, + CASE WHEN $5::timestamptz IS NULL THEN NULL ELSE date_trunc('milliseconds',current_timestamp) END,0)`; + await rejected(app, insert, '23503', [randomUUID(), '00000000-0000-4000-8000-000000000002', + 'a0000000-0000-4000-8000-000000000006', 'fk-probe', new Date()]); + await rejected(app, insert, '23514', [randomUUID(), '00000000-0000-4000-8000-000000000001', + 'a0000000-0000-4000-8000-000000000006', 'null-dispatch-probe', null]); + // This independent untrusted CA is generated for this test, not a TLS opt-out. + const wrong = new pg.Pool({ ...config, user: 'shipments_app', password: process.env.PGPASSWORD, + ssl: { ca: readFileSync(process.env.TEST_WRONG_CA, 'utf8'), rejectUnauthorized: true } }); + try { + await assert.rejects(wrong.query('SELECT 1'), error => { + console.log('Untrusted PG CA TLS code:', error.code); + return ['SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'].includes(error.code); + }); + console.log('Untrusted CA connection rejected before SQL execution'); + } finally { await wrong.end(); } +} finally { await Promise.all([app.end(), cdc.end()]); } diff --git a/applications/shipment-tracker/test/validation.test.ts b/applications/shipment-tracker/test/validation.test.ts new file mode 100644 index 00000000..13fed886 --- /dev/null +++ b/applications/shipment-tracker/test/validation.test.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { TransitionDto } from '../src/dto.js'; +import { assertTransition, deliveredDuration, reportRange } from '../src/domain.js'; +import { TokenScopes } from '../src/auth.js'; + +const options = { whitelist: true, forbidNonWhitelisted: true }; +test('transition input rejects control characters, forged account scope and nonnumeric revisions', () => { + const valid = { requestId: 'dispatch-1', expectedRevision: 0, toStatus: 'dispatched' }; + assert.equal(validateSync(plainToInstance(TransitionDto, valid), options).length, 0); + for (const input of [ + { ...valid, requestId: 'bad\n' }, { ...valid, requestId: 'bad\u0000' }, + { ...valid, expectedRevision: '0' }, { ...valid, accountId: 'foreign' }, + { ...valid, expectedRevision: -1 }, { ...valid, toStatus: 'created' }, + ]) assert.ok(validateSync(plainToInstance(TransitionDto, input), options).length > 0); +}); +test('lifecycle disallows skipped and terminal transitions; duration uses accepted milliseconds', () => { + assert.doesNotThrow(() => assertTransition('created', 'dispatched')); + assert.doesNotThrow(() => assertTransition('dispatched', 'delivered')); + assert.doesNotThrow(() => assertTransition('created', 'cancelled')); + for (const from of ['created', 'delivered', 'cancelled'] as const) { + assert.throws(() => assertTransition(from, 'delivered')); + } + assert.equal(deliveredDuration(new Date('2026-10-02T00:00:02.125Z'), new Date('2026-10-01T23:59:59.100Z')), '3025'); + assert.throws(() => deliveredDuration(new Date(0), new Date(1))); + assert.throws(() => deliveredDuration(new Date(), null)); +}); +test('report date window is UTC-bound and rejects calendar normalization', () => { + const now = new Date('2026-10-02T00:00:00Z'); + assert.deepEqual(reportRange(undefined, undefined, now), ['2026-09-26', '2026-10-02']); + assert.deepEqual(reportRange('2026-09-02', '2026-10-02', now), ['2026-09-02', '2026-10-02']); + for (const [from, to] of [['2026-09-01', '2026-10-02'], ['2026-02-30', '2026-10-02'], ['2026-10-02', '2026-10-03']]) { + assert.throws(() => reportRange(from, to, now)); + } +}); +test('token scopes retain server-derived identity and reject duplicate tokens', () => { + const first = '00000000-0000-4000-8000-000000000001'; + const second = '00000000-0000-4000-8000-000000000002'; + const scopes = new TokenScopes(JSON.stringify({ [first]: 'a'.repeat(64), [second]: 'b'.repeat(64) })); + assert.equal(scopes.account('a'.repeat(64)), first); + assert.equal(scopes.account('c'.repeat(64)), undefined); + assert.throws(() => new TokenScopes(JSON.stringify({ [first]: 'a'.repeat(64), [second]: 'a'.repeat(64) }))); +}); diff --git a/applications/shipment-tracker/tsconfig.json b/applications/shipment-tracker/tsconfig.json new file mode 100644 index 00000000..2618c0d4 --- /dev/null +++ b/applications/shipment-tracker/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", + "strict": true, "esModuleInterop": true, "skipLibCheck": true, + "experimentalDecorators": true, "emitDecoratorMetadata": true, + "rootDir": ".", "outDir": "dist", "sourceMap": true + }, + "include": ["src/**/*.ts", "migrations/**/*.ts", "test/**/*.ts"] +} From 2a43fb77e55246556d2815a566b963e3d4169256 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 2 Oct 2026 21:31:27 +0100 Subject: [PATCH 2/2] docs: remove public beta qualifier --- applications/shipment-tracker/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/applications/shipment-tracker/README.md b/applications/shipment-tracker/README.md index 499b5e08..f4682de5 100644 --- a/applications/shipment-tracker/README.md +++ b/applications/shipment-tracker/README.md @@ -1,6 +1,6 @@ # NestJS shipment tracker -A small account-scoped API tracks seeded shipments through `created → dispatched → delivered`, with cancellation from `created` or `dispatched`. ClickHouse Managed Postgres (public beta) stores current state and immutable accepted transition events. ClickPipes copies those events to analytical ClickHouse; the official Node.js client reads bounded delivery reports. All database services run in ClickHouse Cloud. There is no carrier, address, payment or tracking-provider integration. +A small account-scoped API tracks seeded shipments through `created → dispatched → delivered`, with cancellation from `created` or `dispatched`. ClickHouse Managed Postgres stores current state and immutable accepted transition events. ClickPipes copies those events to analytical ClickHouse; the official Node.js client reads bounded delivery reports. All database services run in ClickHouse Cloud. There is no carrier, address, payment or tracking-provider integration. Shipment creation is a seed/admin operation. The HTTP API provides reads, history and lifecycle transitions. A static server-configured bearer token identifies an account; callers cannot choose their account. The server binds to loopback by default. Deploy behind trusted HTTPS and replace demo tokens with proper identity and access policies before public use.