diff --git a/.env.example b/.env.example index bbe435d..7fdf897 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,14 @@ TRUST_PROXY_CIDRS= # Local Docker Compose host ports. FORMS_HTTP_PORT=3006 FORMS_DATABASE_PORT=5546 + +# Optional outbound Kafka publication through Bus API (required for kafka=true submissions). +# The wrapper adds /bus/events; use the API base ending in /v6. +# BUSAPI_URL=https://api.topcoder-dev.com/v6 +# AUTH0_URL=https://topcoder-dev.auth0.com/oauth/token +# AUTH0_AUDIENCE=https://www.topcoder-dev.com +# AUTH0_CLIENT_ID= +# AUTH0_CLIENT_SECRET= +# TOKEN_CACHE_TIME=86400000 +# AUTH0_PROXY_SERVER_URL= +# KAFKA_ERROR_TOPIC=common.error.reporting diff --git a/README.md b/README.md index e24fbae..7fb610a 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,8 @@ Stop the Compose API first if it already occupies port 3006. `pnpm start:dev` us 3. Publish it. The API creates `forms.event_interest_v1` transactionally. 4. Add a Payload form block referencing `event_interest` to a content page. 5. The website fetches the current API schema and submits the exact displayed revision with a UUID `Idempotency-Key`. -6. Read private paginated JSON/CSV reports or query the SQL view with a reporting database role. +6. Optionally include `"kafka": true` alongside the submitted answers to publish a `form.submitted` event through Bus API; see [the event contract and retry behavior](docs/api.md#optional-kafka-publication). +7. Read private paginated JSON/CSV reports or query the SQL view with a reporting database role. Published definitions are immutable. Changes use a new sequential revision; publication retires the previous one. An already-open older page receives a 409 and must reload. Historical reports remain available. Exact retries of accepted submissions return the original receipt, including after retirement. diff --git a/docs/api.md b/docs/api.md index 873e365..d7082ca 100644 --- a/docs/api.md +++ b/docs/api.md @@ -69,9 +69,42 @@ Idempotency-Key: 6b3d585c-f134-4e06-a6d5-6ea42fc1c7ce Receipts contain no answers or member details. All answers must belong to the pinned stored revision; client-supplied field definitions and extra answer keys are rejected. Server validation does not coerce number or boolean strings. Decimal values are exact strings; multi-select answers are arrays of option keys. Every answer and its selections commit atomically with the envelope. +## Optional Kafka publication + +Include `"kafka": true` in the submission body alongside `version`, `answers`, and `sourcePage` to publish the accepted submission to **`form.submitted`** through the authenticated Topcoder Bus API. The exact string `"true"` is also accepted; omitted, `false`, or `"false"` does not publish. Other non-null values are rejected. This is envelope metadata, not an answer field or query parameter. Answer values retain their strict type validation. + +```json +{ + "version": 1, + "answers": { "email": "member@example.com" }, + "sourcePage": "/events", + "kafka": true +} +``` + +The Bus API envelope uses `topic: "form.submitted"`, `originator: "forms-api-v6"`, `mime-type: "application/json"`, the submission timestamp, and the submission UUID as `key`. Its `payload` is: + +```json +{ + "submissionId": "c1ab5d27-722a-431f-a811-46737109de1f", + "formKey": "event_interest", + "version": 1, + "submittedAt": "2026-09-28T01:00:00.000Z", + "memberId": null, + "sourcePage": "/events", + "answers": { "email": "member@example.com" } +} +``` + +Answers use their validated, canonical values (including exact decimal strings, booleans, numbers, date strings, and option keys); omitted optional answers stay omitted. Member identity comes from verified claims. The honeypot and Kafka flag are excluded from the event. The HTTP receipt remains unchanged. + +Publication happens only after the submission and all answers commit. A successful delivery is recorded separately and identical retries, including concurrent retries, do not publish again. Changing the Kafka opt-in for an existing retry key returns 409; omission and false are equivalent and preserve compatibility with older submissions. + +If Bus API is unavailable or unconfigured, the submission remains saved and the request returns **503**. Retry the identical body and `Idempotency-Key` to resume delivery, even after retirement. There is no background retry worker. A crash or lost acknowledgement after Bus API accepts an event but before the delivery receipt commits can cause redelivery; consumers must deduplicate by `submissionId`. Invalid or rejected submissions never publish. + ## Retry and error handling -Generate one cryptographically random UUID for a logical submission attempt. Reuse it with the same revision, answers, member identity, and source page after a timeout or lost response. The service canonicalizes decimals and multi-select order before hashing. An identical retry returns the original receipt; changed content with that key returns 409. This key remains reserved while the submission is retained. +Generate one cryptographically random UUID for a logical submission attempt. Reuse it with the same revision, answers, member identity, source page, and Kafka opt-in after a timeout or lost response. The service canonicalizes decimals and multi-select order before hashing. An identical retry returns the original receipt; changed content with that key returns 409. This key remains reserved while the submission is retained. A previously accepted attempt can be retried after a version is retired. A new attempt targeting a draft/retired version returns 409. A member-form retry still requires the member token. The API does not replay a submission under a different member identity. diff --git a/docs/data-model.md b/docs/data-model.md index 4d59f73..8eeb745 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -17,6 +17,7 @@ erDiagram FormVersion ||--o{ FormField : defines FormField ||--o{ FieldOption : choices FormVersion ||--o{ Submission : receives + Submission ||--o| SubmissionEvent : delivery Submission ||--o{ Answer : contains FormField ||--o{ Answer : constrains Answer ||--o{ AnswerSelection : selects @@ -32,6 +33,7 @@ erDiagram | `FormField` | Named key unique within a version; explicit type, position, required flag, text length and numeric bounds. | | `FieldOption` | Named option key and label, owned by a field/version. | | `Submission` | Exact version, server timestamp, UUID idempotency key, canonical request hash, verified member ID, and optional source page path. | +| `SubmissionEvent` | Optional one-to-one delivery receipt with submission UUID and nullable publication timestamp; cascades on submission deletion. | | `Answer` | One scalar value in a type-specific column, or a multi-select answer parent; unique per submission/field. | | `AnswerSelection` | One row per selected option, with composite ownership foreign keys and duplicate prevention. | @@ -48,6 +50,8 @@ The API additionally validates email syntax, exact decimal input precision, real API form registration and revisions are serialized on the stable form row. Publication, retirement, and submission acceptance use the same lock. This favors simple consistency for occasional website forms; it serializes submissions to the same form. Revisit this locking strategy if measured submission volume requires greater throughput. +Kafka opt-in creates a `SubmissionEvent` row atomically with the submission. Its publication timestamp is updated after Bus API acceptance in a separate transaction; submission envelopes and answers remain immutable. Delivery retries lock this row to prevent concurrent duplicate sends. A failed or unacknowledged delivery remains pending until the caller retries. No JSON payload is stored; the validated request and immutable revision reconstruct the event. + ## Field semantics - `INTEGER` is PostgreSQL `integer` (signed 32-bit), sent as a JSON number. diff --git a/docs/operations.md b/docs/operations.md index a8a5f91..22b0831 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -20,6 +20,28 @@ All Forms tables, enums, functions, migration history, and reporting views live The Prisma client uses the PostgreSQL driver adapter and generated TypeScript code. Connection URLs are configured in `prisma.config.ts`, consistent with the [Prisma 7 migration guide](https://www.prisma.io/docs/orm/more/upgrade-guides/upgrading-versions/upgrading-to-prisma-7). Client generation and compilation require no live database. Migrations and runtime require `DATABASE_URL`. +## Outbound Bus API + +Ordinary submissions need no Bus API configuration. To accept `kafka=true` submissions successfully, configure: + +| Variable | Meaning | +| --- | --- | +| `BUSAPI_URL` | HTTP(S) API base ending in `/v6`, e.g. `https://api.topcoder-dev.com/v6`. The shared wrapper appends `/bus/events`. | +| `AUTH0_URL` | Auth0 token endpoint used by the shared Topcoder M2M client. | +| `AUTH0_AUDIENCE` | Outbound M2M audience; separate from inbound `AUTH_AUDIENCE`. | +| `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET` | Service credentials authorized to publish Bus API events. Required when `BUSAPI_URL` is set. | +| `TOKEN_CACHE_TIME` | Optional M2M token cache duration in milliseconds, 0–86400000; otherwise uses the wrapper default. | +| `AUTH0_PROXY_SERVER_URL` | Optional Auth0 proxy supported by the shared wrapper. | +| `KAFKA_ERROR_TOPIC` | Wrapper error-topic setting, default `common.error.reporting`; submission topic is always `form.submitted`. | + +For ECS, expose the five required Bus API/Auth0 settings to the runtime container through its task-definition secrets (the existing template only maps inbound authentication and database settings). Store them under the service parameter prefix so its existing SSM read permissions apply; never put credentials in the template. + +Apply migration `20260928010000_submission_events` before deploying this version. It adds the `forms.SubmissionEvent` delivery table; it does not change existing submissions or reporting views. The service role needs SELECT/INSERT/UPDATE on this table. Ensure `form.submitted` is available through Bus API and downstream consumers deduplicate on the payload's `submissionId`. + +Delivery failures return 503 after saving the submission; callers must retry the same body and key. Pending attempts have `publishedAt IS NULL` in `forms.SubmissionEvent`. No background delivery job is included. Provider error bodies, credentials, and answers are not logged by the publisher. Delivery uses a separate transaction with a row lock and a 15-second transaction timeout; failure to record an accepted event may result in redelivery. + +The shared wrapper and its Topcoder core dependency are pinned to Git commits. `pnpm-workspace.yaml` allows Git subdependencies for this integration and explicitly skips optional native DTrace builds. + ## Database access The checked-in migrations create tables, enums, foreign keys, CHECKs, indexes, and lifecycle/answer triggers inside `forms`. Use `pnpm migrate:deploy`; **do not use `prisma db push`**, which does not reproduce the custom integrity triggers and checks. diff --git a/package.json b/package.json index c808a51..3fdbf82 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "jose": "6.2.10", "pg": "8.23.0", "reflect-metadata": "0.2.2", - "rxjs": "7.8.2" + "rxjs": "7.8.2", + "tc-bus-api-wrapper": "github:topcoder-platform/tc-bus-api-wrapper#297a9c0adcdb97661257e7825bee9c3f5578b833" }, "devDependencies": { "@eslint/js": "9.39.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a70b832..d91ceb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + tc-core-library-js: github:topcoder-platform/tc-core-library-js#323567bc50e433ae488b656f9f94e821ebaf3062 + importers: .: @@ -56,6 +59,9 @@ importers: rxjs: specifier: 7.8.2 version: 7.8.2 + tc-bus-api-wrapper: + specifier: github:topcoder-platform/tc-bus-api-wrapper#297a9c0adcdb97661257e7825bee9c3f5578b833 + version: '@topcoder-platform/topcoder-bus-api-wrapper@https://codeload.github.com/topcoder-platform/tc-bus-api-wrapper/tar.gz/297a9c0adcdb97661257e7825bee9c3f5578b833(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)' devDependencies: '@eslint/js': specifier: 9.39.2 @@ -408,6 +414,26 @@ packages: '@noble/hashes': optional: true + '@hapi/address@5.1.1': + resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} + engines: {node: '>=14.0.0'} + + '@hapi/formula@3.0.2': + resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==} + + '@hapi/hoek@11.0.7': + resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} + + '@hapi/pinpoint@2.0.1': + resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} + + '@hapi/tlds@1.1.7': + resolution: {integrity: sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==} + engines: {node: '>=14.0.0'} + + '@hapi/topo@6.0.2': + resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -943,6 +969,10 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@topcoder-platform/topcoder-bus-api-wrapper@https://codeload.github.com/topcoder-platform/tc-bus-api-wrapper/tar.gz/297a9c0adcdb97661257e7825bee9c3f5578b833': + resolution: {gitHosted: true, tarball: https://codeload.github.com/topcoder-platform/tc-bus-api-wrapper/tar.gz/297a9c0adcdb97661257e7825bee9c3f5578b833} + version: 1.2.0 + '@tsconfig/node10@1.0.13': resolution: {integrity: sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==} @@ -1024,12 +1054,18 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/lodash@4.17.25': resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@26.0.0': resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} @@ -1253,6 +1289,13 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} + axios@0.30.3: + resolution: {integrity: sha512-5/tmEb6TmE/ax3mdXBc/Mi6YdPGxQsv+0p5YlciXWt3PHIn0VamqCXhRMtScnwY3lbgSXLneOuXAKUhgmSRpwg==} + + backoff@2.5.0: + resolution: {integrity: sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==} + engines: {node: '>= 0.6'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1275,9 +1318,17 @@ packages: bson-objectid@2.0.4: resolution: {integrity: sha512-vgnKAUzcDoa+AeyYwXCoHyF2q6u/8H46dxu5JN+4/TZeq/Dlinn0K6GvxsCLb3LHUJl0m/TLiEK31kUwtgocMQ==} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bunyan@1.8.15: + resolution: {integrity: sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==} + engines: {'0': node >=0.10.0} + hasBin: true + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -1331,6 +1382,9 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + codependency@2.1.0: + resolution: {integrity: sha512-JIdmYkE8Z6jwH1OUf4a5H5jk9YShPQkaYPUAiN+ktyChmPP77LGbeKrxWGPqdCnpTmt0hRIn8TXBVu01U3HDhg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1533,10 +1587,17 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + dtrace-provider@0.8.8: + resolution: {integrity: sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==} + engines: {node: '>=0.10'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -1724,6 +1785,15 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1777,6 +1847,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@6.0.4: + resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -1873,6 +1947,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -1916,6 +1994,13 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + joi@18.2.9: + resolution: {integrity: sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==} + engines: {node: '>= 20'} + + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@5.10.0: resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} @@ -1960,6 +2045,23 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jwks-rsa@3.2.2: + resolution: {integrity: sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==} + engines: {node: '>=14'} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1970,6 +2072,9 @@ packages: libphonenumber-js@1.13.12: resolution: {integrity: sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==} + limiter@1.1.5: + resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==} + load-esm@1.0.3: resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} engines: {node: '>=13.2.0'} @@ -1982,9 +2087,33 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -1995,6 +2124,13 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-memoizer@2.3.0: + resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} + lru.min@1.1.5: resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} @@ -2028,6 +2164,9 @@ packages: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} + millisecond@0.1.2: + resolution: {integrity: sha512-BJ8XtxY+woL+5TkP6uS6XvOArm0JVrX2otkgtWZseHpIax0oOOPW3cnwhOjRqbEJg7YRO/BDF7fO/PTWNT3T9Q==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -2059,6 +2198,13 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + moment@2.31.0: + resolution: {integrity: sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2066,6 +2212,10 @@ packages: resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} engines: {node: '>= 10.16.0'} + mv@2.1.1: + resolution: {integrity: sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==} + engines: {node: '>=0.8.0'} + mysql2@3.15.3: resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} engines: {node: '>= 8.0'} @@ -2074,6 +2224,9 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} + nan@2.29.0: + resolution: {integrity: sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==} + nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2082,6 +2235,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + ncp@2.0.0: + resolution: {integrity: sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==} + hasBin: true + negotiator@1.1.0: resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} engines: {node: '>=18'} @@ -2139,6 +2296,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2252,6 +2413,10 @@ packages: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} + precond@0.2.3: + resolution: {integrity: sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==} + engines: {node: '>= 0.6'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2284,6 +2449,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -2305,6 +2473,10 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + r7insight_node@2.1.1: + resolution: {integrity: sha512-xx0kgFxSHWY9aG1109uv4w2b+JLwHseSowOWo1bzCTDBpUk3er2rZdtQ90mAjUYbkh6Hus9DAwWvmHsX5pHaIQ==} + engines: {node: ^8.10.0 || ^10.13.0 || >=11.10.1} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -2341,6 +2513,9 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + reconnect-core@1.3.0: + resolution: {integrity: sha512-+gLKwmyRf2tjl6bLR03DoeWELzyN6LW9Xgr3vh7NXHHwPi0JC0N2TwPyf90oUEBkCRcD+bgQ+s3HORoG3nwHDg==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -2366,6 +2541,11 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + rimraf@2.4.5: + resolution: {integrity: sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -2384,6 +2564,9 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-json-stringify@1.2.0: + resolution: {integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==} + safe-regex2@5.1.1: resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} hasBin: true @@ -2408,6 +2591,10 @@ packages: secure-json-parse@4.1.0: resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -2526,6 +2713,11 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062: + resolution: {gitHosted: true, tarball: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062} + version: 3.0.1 + engines: {node: '>= 14'} + thread-stream@3.2.0: resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==} @@ -2830,6 +3022,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} engines: {node: '>=6'} @@ -3037,6 +3232,22 @@ snapshots: optionalDependencies: '@noble/hashes': 1.8.0 + '@hapi/address@5.1.1': + dependencies: + '@hapi/hoek': 11.0.7 + + '@hapi/formula@3.0.2': {} + + '@hapi/hoek@11.0.7': {} + + '@hapi/pinpoint@2.0.1': {} + + '@hapi/tlds@1.1.7': {} + + '@hapi/topo@6.0.2': + dependencies: + '@hapi/hoek': 11.0.7 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3487,6 +3698,16 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@topcoder-platform/topcoder-bus-api-wrapper@https://codeload.github.com/topcoder-platform/tc-bus-api-wrapper/tar.gz/297a9c0adcdb97661257e7825bee9c3f5578b833(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + joi: 18.2.9 + lodash: 4.18.1 + superagent: 10.3.0(supports-color@7.2.0) + tc-core-library-js: https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - debug + - supports-color + '@tsconfig/node10@1.0.13': {} '@tsconfig/node12@1.0.11': {} @@ -3568,10 +3789,17 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 26.0.0 + '@types/lodash@4.17.25': {} '@types/methods@1.1.4': {} + '@types/ms@2.1.0': {} + '@types/node@26.0.0': dependencies: undici-types: 8.3.0 @@ -3888,6 +4116,18 @@ snapshots: aws-ssl-profiles@1.1.2: {} + axios@0.30.3(debug@4.4.3(supports-color@7.2.0)): + dependencies: + follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0)) + form-data: 4.0.6 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + backoff@2.5.0: + dependencies: + precond: 0.2.3 + balanced-match@1.0.2: {} better-result@2.10.0: {} @@ -3921,8 +4161,17 @@ snapshots: bson-objectid@2.0.4: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} + bunyan@1.8.15: + optionalDependencies: + dtrace-provider: 0.8.8 + moment: 2.31.0 + mv: 2.1.1 + safe-json-stringify: 1.2.0 + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -3979,6 +4228,10 @@ snapshots: classnames@2.5.1: {} + codependency@2.1.0: + dependencies: + semver: 5.7.2 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4147,12 +4400,21 @@ snapshots: dotenv@17.4.2: {} + dtrace-provider@0.8.8: + dependencies: + nan: 2.29.0 + optional: true + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.20.0: @@ -4405,6 +4667,10 @@ snapshots: flatted@3.4.4: {} + follow-redirects@1.16.0(debug@4.4.3(supports-color@7.2.0)): + optionalDependencies: + debug: 4.4.3(supports-color@7.2.0) + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -4467,6 +4733,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@6.0.4: + dependencies: + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + optional: true + globals@14.0.0: {} globals@16.5.0: {} @@ -4546,6 +4821,12 @@ snapshots: imurmurhash@0.1.4: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + optional: true + inherits@2.0.4: {} internmap@2.0.3: {} @@ -4572,6 +4853,18 @@ snapshots: jiti@2.7.0: {} + joi@18.2.9: + dependencies: + '@hapi/address': 5.1.1 + '@hapi/formula': 3.0.2 + '@hapi/hoek': 11.0.7 + '@hapi/pinpoint': 2.0.1 + '@hapi/tlds': 1.1.7 + '@hapi/topo': 6.0.2 + '@standard-schema/spec': 1.1.0 + + jose@4.15.9: {} + jose@5.10.0: {} jose@6.2.10: {} @@ -4634,6 +4927,42 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwks-rsa@3.2.2(supports-color@7.2.0): + dependencies: + '@types/jsonwebtoken': 9.0.10 + debug: 4.4.3(supports-color@7.2.0) + jose: 4.15.9 + limiter: 1.1.5 + lru-memoizer: 2.3.0 + transitivePeerDependencies: + - supports-color + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -4645,6 +4974,8 @@ snapshots: libphonenumber-js@1.13.12: {} + limiter@1.1.5: {} + load-esm@1.0.3: {} load-tsconfig@0.2.5: {} @@ -4653,14 +4984,39 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.clonedeep@4.5.0: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash@4.18.1: {} long@5.3.2: {} lru-cache@11.5.2: {} + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-memoizer@2.3.0: + dependencies: + lodash.clonedeep: 4.5.0 + lru-cache: 6.0.0 + lru.min@1.1.5: {} magic-string@0.30.21: @@ -4681,6 +5037,8 @@ snapshots: methods@1.1.2: {} + millisecond@0.1.2: {} + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -4705,6 +5063,14 @@ snapshots: minimist@1.2.8: {} + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + optional: true + + moment@2.31.0: + optional: true + ms@2.1.3: {} multer@2.2.0: @@ -4714,6 +5080,13 @@ snapshots: concat-stream: 2.0.0 type-is: 1.6.18 + mv@2.1.1: + dependencies: + mkdirp: 0.5.6 + ncp: 2.0.0 + rimraf: 2.4.5 + optional: true + mysql2@3.15.3: dependencies: aws-ssl-profiles: 1.1.2 @@ -4730,10 +5103,16 @@ snapshots: dependencies: lru.min: 1.1.5 + nan@2.29.0: + optional: true + nanoid@3.3.18: {} natural-compare@1.4.0: {} + ncp@2.0.0: + optional: true + negotiator@1.1.0: dependencies: content-type: 2.1.0 @@ -4785,6 +5164,9 @@ snapshots: path-exists@4.0.0: {} + path-is-absolute@1.0.1: + optional: true + path-key@3.1.1: {} path-to-regexp@6.3.0: {} @@ -4938,6 +5320,8 @@ snapshots: postgres@3.4.7: {} + precond@0.2.3: {} + prelude-ls@1.2.1: {} prettier@3.8.1: {} @@ -4972,6 +5356,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -4990,6 +5376,13 @@ snapshots: quick-format-unescaped@4.0.4: {} + r7insight_node@2.1.1: + dependencies: + codependency: 2.1.0 + json-stringify-safe: 5.0.1 + lodash: 4.18.1 + reconnect-core: 1.3.0 + range-parser@1.2.1: {} range-parser@1.3.0: {} @@ -5023,6 +5416,10 @@ snapshots: real-require@0.2.0: {} + reconnect-core@1.3.0: + dependencies: + backoff: 2.5.0 + reflect-metadata@0.2.2: {} remeda@2.33.4: {} @@ -5037,6 +5434,11 @@ snapshots: retry@0.12.0: {} + rimraf@2.4.5: + dependencies: + glob: 6.0.4 + optional: true + robust-predicates@3.0.3: {} rollup@4.63.1: @@ -5087,6 +5489,9 @@ snapshots: safe-buffer@5.2.1: {} + safe-json-stringify@1.2.0: + optional: true + safe-regex2@5.1.1: dependencies: ret: 0.5.0 @@ -5107,6 +5512,8 @@ snapshots: secure-json-parse@4.1.0: {} + semver@5.7.2: {} + semver@7.8.5: {} send@1.2.1(supports-color@7.2.0): @@ -5242,6 +5649,19 @@ snapshots: symbol-tree@3.2.4: {} + tc-core-library-js@https://codeload.github.com/topcoder-platform/tc-core-library-js/tar.gz/323567bc50e433ae488b656f9f94e821ebaf3062(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + axios: 0.30.3(debug@4.4.3(supports-color@7.2.0)) + bunyan: 1.8.15 + jsonwebtoken: 9.0.3 + jwks-rsa: 3.2.2(supports-color@7.2.0) + lodash: 4.18.1 + millisecond: 0.1.2 + r7insight_node: 2.1.1 + transitivePeerDependencies: + - debug + - supports-color + thread-stream@3.2.0: dependencies: real-require: 0.2.0 @@ -5487,6 +5907,8 @@ snapshots: xtend@4.0.2: {} + yallist@4.0.0: {} + yn@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6323775..ef9b100 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,8 @@ allowBuilds: esbuild: true prisma: true '@scarf/scarf': false + dtrace-provider: false +# The shared Bus API wrapper depends on Topcoder's Git-hosted core library. +blockExoticSubdeps: false +overrides: + tc-core-library-js: github:topcoder-platform/tc-core-library-js#323567bc50e433ae488b656f9f94e821ebaf3062 diff --git a/prisma/migrations/20260928010000_submission_events/migration.sql b/prisma/migrations/20260928010000_submission_events/migration.sql new file mode 100644 index 0000000..00596a3 --- /dev/null +++ b/prisma/migrations/20260928010000_submission_events/migration.sql @@ -0,0 +1,8 @@ +-- Track Bus API delivery without changing immutable submission envelopes. +CREATE TABLE "forms"."SubmissionEvent" ( + "submissionId" UUID NOT NULL, + "publishedAt" TIMESTAMPTZ(3), + CONSTRAINT "SubmissionEvent_pkey" PRIMARY KEY ("submissionId"), + CONSTRAINT "SubmissionEvent_submissionId_fkey" FOREIGN KEY ("submissionId") + REFERENCES "forms"."Submission"("id") ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a0c0204..6bf7988 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -129,6 +129,7 @@ model Submission { createdAt DateTime @default(now()) @db.Timestamptz(3) version FormVersion @relation(fields: [versionId], references: [id], onDelete: Restrict) answers Answer[] + event SubmissionEvent? @@unique([versionId, idempotencyKey]) @@unique([id, versionId]) @@ -177,3 +178,12 @@ model AnswerSelection { @@schema("forms") } + +/// Delivery receipt for an opted-in submission; separate from its immutable envelope. +model SubmissionEvent { + submissionId String @id @db.Uuid + publishedAt DateTime? @db.Timestamptz(3) + submission Submission @relation(fields: [submissionId], references: [id], onDelete: Cascade) + + @@schema("forms") +} diff --git a/src/app.module.ts b/src/app.module.ts index a4fdd19..462703a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { CONFIG, type AppConfig } from './config'; import { DbService } from './db.service'; import { FormsController } from './forms/forms.controller'; import { FormsService } from './forms/forms.service'; +import { EventBusService } from './integrations/event-bus.service'; import { HealthController } from './health.controller'; /** Composes the forms service with explicit configuration, authentication, and request throttling. */ @@ -27,6 +28,7 @@ export class AppModule { { provide: CONFIG, useValue: config }, DbService, FormsService, + EventBusService, { provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_GUARD, useClass: AuthGuard }, ], diff --git a/src/config.ts b/src/config.ts index 33abe7b..0d1a872 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,10 @@ import 'dotenv/config'; +import type { BusApiConfiguration } from 'tc-bus-api-wrapper'; /** Runtime configuration validated once before Nest starts serving requests. */ export interface AppConfig { databaseUrl: string; + busApi?: BusApiConfiguration; port: number; origins: string[]; authMode: 'hs256' | 'jwks'; @@ -29,9 +31,7 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { (database.searchParams.has('schema') && database.searchParams.get('schema') !== 'forms') ) { - throw new Error( - 'DATABASE_URL must be PostgreSQL using the forms schema.', - ); + throw new Error('DATABASE_URL must be PostgreSQL using the forms schema.'); } database.searchParams.set('schema', 'forms'); const authMode = env.AUTH_MODE ?? 'jwks'; @@ -62,6 +62,7 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { throw new Error('VALID_ISSUERS must contain at least one issuer.'); return { databaseUrl: database.toString(), + busApi: readBusConfig(env), port: integerSetting(env.PORT ?? '3000', 1, 65535), origins, authMode, @@ -107,3 +108,41 @@ function integerSetting(raw: string, min: number, max: number): number { } export const CONFIG = Symbol('forms.config'); + +/** + * Reads optional outbound Bus API settings without requiring them for ordinary forms. + * @param env Environment map used by readConfig. + * @returns Wrapper configuration when BUSAPI_URL is set, otherwise undefined. + * @throws Error for partial credentials, invalid URLs, or invalid cache duration. + */ +function readBusConfig( + env: NodeJS.ProcessEnv, +): BusApiConfiguration | undefined { + if (!env.BUSAPI_URL?.trim()) return undefined; + const url = new URL(env.BUSAPI_URL.trim()); + url.pathname = url.pathname.replace(/\/+$/, ''); + if ( + !['http:', 'https:'].includes(url.protocol) || + !url.pathname.endsWith('/v6') || + url.search || + url.hash || + url.username || + url.password + ) + throw new Error('BUSAPI_URL must be an HTTP(S) API base ending in /v6.'); + return { + BUSAPI_URL: url.toString().replace(/\/$/, ''), + AUTH0_URL: required(env, 'AUTH0_URL'), + AUTH0_AUDIENCE: required(env, 'AUTH0_AUDIENCE'), + AUTH0_CLIENT_ID: required(env, 'AUTH0_CLIENT_ID'), + AUTH0_CLIENT_SECRET: required(env, 'AUTH0_CLIENT_SECRET'), + KAFKA_ERROR_TOPIC: + env.KAFKA_ERROR_TOPIC?.trim() || 'common.error.reporting', + ...(env.TOKEN_CACHE_TIME + ? { TOKEN_CACHE_TIME: integerSetting(env.TOKEN_CACHE_TIME, 0, 86400000) } + : {}), + ...(env.AUTH0_PROXY_SERVER_URL?.trim() + ? { AUTH0_PROXY_SERVER_URL: env.AUTH0_PROXY_SERVER_URL.trim() } + : {}), + }; +} diff --git a/src/forms/dto.ts b/src/forms/dto.ts index 7054a48..7d39e93 100644 --- a/src/forms/dto.ts +++ b/src/forms/dto.ts @@ -1,4 +1,4 @@ -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { ArrayMaxSize, ArrayMinSize, @@ -141,6 +141,18 @@ export class SubmissionDto { @IsString() @MaxLength(0) website?: string; + @ApiPropertyOptional({ + type: Boolean, + default: false, + description: + 'Publish the accepted submission to form.submitted through Bus API.', + }) + @IsOptional() + @Transform(({ value }) => + value === 'true' ? true : value === 'false' ? false : value, + ) + @IsBoolean() + kafka?: boolean; } /** Defines bounded keyset pagination for reporting; only cursors in this version are accepted. */ diff --git a/src/forms/forms.controller.ts b/src/forms/forms.controller.ts index 5924cdd..60a1077 100644 --- a/src/forms/forms.controller.ts +++ b/src/forms/forms.controller.ts @@ -107,7 +107,7 @@ export class FormsController { return this.forms.retire(key, version, request.actor!); } - /** Saves an envelope under a UUID retry key; returns a receipt or validation, authentication, stale-version, or conflict errors. */ + /** Saves an envelope under a UUID retry key; returns a receipt or validation, authentication, stale-version, conflict, or optional Bus API delivery errors. */ @Post(':key/submissions') @Access('public') @ApiHeader({ @@ -117,7 +117,8 @@ export class FormsController { 'One random UUID per attempted submission, reused unchanged on network retries.', }) @ApiOperation({ - summary: 'Submit against the exact version displayed to the visitor', + summary: + 'Submit against the displayed version; optionally publish with kafka=true', }) submit( @Param('key') key: string, diff --git a/src/forms/forms.service.ts b/src/forms/forms.service.ts index c5b6f33..0a540aa 100644 --- a/src/forms/forms.service.ts +++ b/src/forms/forms.service.ts @@ -8,6 +8,10 @@ import { } from '@nestjs/common'; import { createHash, randomUUID } from 'node:crypto'; import { isUUID } from 'class-validator'; +import { + EventBusService, + type FormSubmittedPayload, +} from '../integrations/event-bus.service'; import { DbService } from '../db.service'; import { Prisma } from '../generated/prisma/client'; import { FieldType, FormStatus } from '../generated/prisma/enums'; @@ -31,14 +35,17 @@ type Definition = Prisma.FormVersionGetPayload<{ include: typeof definitionInclude; }>; -/** Manages immutable form revisions, publication, typed submission writes, and private reports. */ +/** Manages immutable form revisions, publication, typed submission writes, optional Bus API events, and private reports. */ @Injectable() export class FormsService { /** * Creates the domain service used by the HTTP controller. - * @param db Shared Prisma connection. @throws No errors. + * @param db Shared Prisma connection. @param events Optional submission event publisher. @throws No errors. */ - constructor(private readonly db: DbService) {} + constructor( + private readonly db: DbService, + private readonly events: EventBusService, + ) {} /** * Idempotently registers a specifically named form. @@ -262,10 +269,10 @@ export class FormsService { } /** - * Validates against the pinned version and saves the entire typed submission in one transaction. - * @param key Form key. @param input Answer envelope. @param idempotencyKey Caller-generated UUID. @param actor Optional verified member. + * Saves the typed submission atomically, then publishes requested events after commit. + * @param key Form key. @param input Answer envelope with optional Kafka opt-in. @param idempotencyKey Caller-generated UUID. @param actor Optional verified member. * @returns Receipt without answers or identity; exact retries return the original receipt even after retirement. - * @throws BadRequestException for invalid answers; UnauthorizedException for missing member identity; ForbiddenException for machine submissions; ConflictException for stale versions or changed retry payloads. + * @throws BadRequestException for invalid answers; UnauthorizedException for missing member identity; ForbiddenException for machine submissions; ConflictException for stale versions or changed retry payloads; ServiceUnavailableException for Bus API failure (retry the same request). */ async submit( key: string, @@ -281,7 +288,7 @@ export class FormsService { throw new ForbiddenException( 'Machine tokens cannot submit visitor forms.', ); - return this.db.$transaction( + const result = await this.db.$transaction( async (tx) => { await this.lockForm(tx, key); const definition = await this.findVersion(tx, key, input.version); @@ -292,10 +299,20 @@ export class FormsService { const validated = validateAnswers(definition.fields, input.answers); const requestHash = digest({ version: input.version, + // Preserve hashes for existing submissions that did not request Kafka. + ...(input.kafka === true ? { kafka: true } : {}), memberId: actor?.memberId ?? null, sourcePage: input.sourcePage ?? null, answers: validated.map(({ field, value }) => [field.key, value]), }); + const eventData = { + formKey: key, + memberId: actor?.memberId ?? null, + sourcePage: input.sourcePage ?? null, + answers: Object.fromEntries( + validated.map(({ field, value }) => [field.key, value]), + ), + }; const existing = await tx.submission.findUnique({ where: { versionId_idempotencyKey: { @@ -310,6 +327,7 @@ export class FormsService { 'Idempotency-Key was already used for a different submission.', ); return { + eventData, id: existing.id, version: input.version, submittedAt: existing.createdAt, @@ -326,11 +344,13 @@ export class FormsService { requestHash, memberId: actor?.memberId, sourcePage: input.sourcePage, + ...(input.kafka === true ? { event: { create: {} } } : {}), }, }); for (const answer of validated) await this.writeAnswer(tx, submission.id, definition.id, answer); return { + eventData, id: submission.id, version: input.version, submittedAt: submission.createdAt, @@ -338,6 +358,43 @@ export class FormsService { }, { timeout: 15000 }, ); + const { eventData, ...receipt } = result; + if (input.kafka === true) { + await this.publishSubmission({ + ...eventData, + submissionId: receipt.id, + version: receipt.version, + submittedAt: receipt.submittedAt.toISOString(), + }); + } + return receipt; + } + + /** + * Serializes delivery attempts for a committed submission across service replicas. + * @param payload Validated submission data with its stable receipt identity. + * @returns Nothing once delivery is recorded or was already completed. + * @throws Bus API or database errors; an identical submission retry resumes delivery. + */ + private async publishSubmission( + payload: FormSubmittedPayload, + ): Promise { + await this.db.$transaction( + async (tx) => { + const [event] = await tx.$queryRaw<{ publishedAt: Date | null }[]>` + SELECT "publishedAt" FROM "forms"."SubmissionEvent" + WHERE "submissionId" = ${payload.submissionId}::uuid FOR UPDATE + `; + if (!event) throw new Error('Submission event receipt is missing.'); + if (event.publishedAt) return; + await this.events.publishSubmission(payload); + await tx.submissionEvent.update({ + where: { submissionId: payload.submissionId }, + data: { publishedAt: new Date() }, + }); + }, + { timeout: 15000 }, + ); } /** diff --git a/src/integrations/event-bus.service.ts b/src/integrations/event-bus.service.ts new file mode 100644 index 0000000..058656d --- /dev/null +++ b/src/integrations/event-bus.service.ts @@ -0,0 +1,58 @@ +import { + Inject, + Injectable, + ServiceUnavailableException, +} from '@nestjs/common'; +import createBusApiClient, { type BusApiClient } from 'tc-bus-api-wrapper'; +import { CONFIG, type AppConfig } from '../config'; +import type { AnswerValue } from '../forms/validation'; + +/** Validated submission event contract consumed by downstream form processors. */ +export interface FormSubmittedPayload { + submissionId: string; + formKey: string; + version: number; + submittedAt: string; + memberId: string | null; + sourcePage: string | null; + answers: Record; +} + +/** Publishes opted-in form submissions using the shared authenticated Topcoder Bus API wrapper. */ +@Injectable() +export class EventBusService { + private readonly client?: BusApiClient; + + /** + * Initializes the shared wrapper when outbound Bus API settings are configured. + * @param config Validated application settings injected by Nest. + * @throws Error if the wrapper rejects supplied credentials or URLs. + */ + constructor(@Inject(CONFIG) config: AppConfig) { + if (config.busApi) this.client = createBusApiClient(config.busApi); + } + + /** + * Sends the standard event envelope to the fixed form.submitted topic. + * @param payload Validated answers and server-derived receipt/member metadata. + * @returns Nothing once Bus API accepts the event. + * @throws ServiceUnavailableException when unconfigured or delivery fails; never exposes provider errors or submitted data. + */ + async publishSubmission(payload: FormSubmittedPayload): Promise { + try { + if (!this.client) throw new Error('Bus API is not configured.'); + await this.client.postEvent({ + topic: 'form.submitted', + originator: 'forms-api-v6', + timestamp: payload.submittedAt, + 'mime-type': 'application/json', + key: payload.submissionId, + payload, + }); + } catch { + throw new ServiceUnavailableException( + 'Submission saved but event delivery failed. Retry with the same Idempotency-Key and body.', + ); + } + } +} diff --git a/src/types/tc-bus-api-wrapper.d.ts b/src/types/tc-bus-api-wrapper.d.ts new file mode 100644 index 0000000..612dcd3 --- /dev/null +++ b/src/types/tc-bus-api-wrapper.d.ts @@ -0,0 +1,33 @@ +declare module 'tc-bus-api-wrapper' { + /** Configuration accepted by the shared Topcoder Bus API wrapper. */ + export interface BusApiConfiguration { + AUTH0_URL: string; + AUTH0_AUDIENCE: string; + AUTH0_CLIENT_ID: string; + AUTH0_CLIENT_SECRET: string; + BUSAPI_URL: string; + KAFKA_ERROR_TOPIC: string; + TOKEN_CACHE_TIME?: number; + AUTH0_PROXY_SERVER_URL?: string; + } + + /** Complete legacy-compatible event envelope published through Bus API. */ + export interface BusApiEvent { + topic: string; + originator: string; + timestamp: string; + 'mime-type': 'application/json'; + payload: T; + key?: string; + } + + /** Subset of the shared wrapper used by Forms API. */ + export interface BusApiClient { + postEvent(event: BusApiEvent): Promise; + } + + /** Creates an authenticated client whose base URL is the Topcoder API v6 base. */ + export default function createBusApiClient( + config: BusApiConfiguration, + ): BusApiClient; +} diff --git a/test/event-bus.spec.ts b/test/event-bus.spec.ts new file mode 100644 index 0000000..76c9ba7 --- /dev/null +++ b/test/event-bus.spec.ts @@ -0,0 +1,106 @@ +import 'reflect-metadata'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readConfig } from '../src/config'; +import { EventBusService } from '../src/integrations/event-bus.service'; +import { testSecret } from './fixtures'; + +const { postEvent, createClient } = vi.hoisted(() => { + const postEvent = vi.fn(); + return { postEvent, createClient: vi.fn(() => ({ postEvent })) }; +}); +vi.mock('tc-bus-api-wrapper', () => ({ default: createClient })); + +const baseEnv = { + DATABASE_URL: 'postgresql://localhost/forms', + AUTH_MODE: 'hs256', + AUTH_SECRET: testSecret, + VALID_ISSUERS: 'https://forms.test', + AUTH_AUDIENCE: 'forms-api', +}; +const busEnv = { + BUSAPI_URL: 'https://api.topcoder-dev.com/v6/', + AUTH0_URL: 'https://auth.test', + AUTH0_AUDIENCE: 'https://api.test', + AUTH0_CLIENT_ID: 'client', + AUTH0_CLIENT_SECRET: 'secret', +}; +const payload = { + submissionId: 'c1ab5d27-722a-431f-a811-46737109de1f', + formKey: 'event_interest', + version: 1, + submittedAt: '2026-09-28T01:00:00.000Z', + memberId: null, + sourcePage: null, + answers: { updates: false, age: 0, email: 'member@example.com' }, +}; + +describe('outbound Bus API integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + postEvent.mockReset().mockResolvedValue(undefined); + }); + + it('configures the shared wrapper and sends the standard event envelope', async () => { + const config = readConfig({ + ...baseEnv, + ...busEnv, + TOKEN_CACHE_TIME: '60000', + AUTH0_PROXY_SERVER_URL: 'https://proxy.test', + }); + const bus = new EventBusService(config); + expect(createClient).toHaveBeenCalledWith({ + ...busEnv, + BUSAPI_URL: 'https://api.topcoder-dev.com/v6', + TOKEN_CACHE_TIME: 60000, + AUTH0_PROXY_SERVER_URL: 'https://proxy.test', + KAFKA_ERROR_TOPIC: 'common.error.reporting', + }); + await bus.publishSubmission(payload); + expect(postEvent).toHaveBeenCalledExactlyOnceWith({ + topic: 'form.submitted', + originator: 'forms-api-v6', + timestamp: payload.submittedAt, + 'mime-type': 'application/json', + key: payload.submissionId, + payload, + }); + }); + + it('allows ordinary forms without outbound credentials and explicitly fails requested delivery', async () => { + const config = readConfig(baseEnv); + expect(config.busApi).toBeUndefined(); + const bus = new EventBusService(config); + expect(createClient).not.toHaveBeenCalled(); + await expect(bus.publishSubmission(payload)).rejects.toMatchObject({ + status: 503, + }); + }); + + it('does not expose provider errors to callers', async () => { + postEvent.mockRejectedValue(new Error('credentials and payload')); + const bus = new EventBusService(readConfig({ ...baseEnv, ...busEnv })); + await expect(bus.publishSubmission(payload)).rejects.toThrow( + 'Retry with the same Idempotency-Key and body', + ); + }); + + it.each([ + 'https://api.test/v5', + 'https://api.test/v6/bus/events', + 'ftp://api.test/v6', + 'https://api.test/v6?x=1', + ])('rejects invalid Bus API base %s', (BUSAPI_URL) => { + expect(() => readConfig({ ...baseEnv, ...busEnv, BUSAPI_URL })).toThrow( + 'BUSAPI_URL', + ); + }); + + it.each([ + 'AUTH0_URL', + 'AUTH0_AUDIENCE', + 'AUTH0_CLIENT_ID', + 'AUTH0_CLIENT_SECRET', + ])('requires %s when bus is enabled', (key) => { + expect(() => readConfig({ ...baseEnv, ...busEnv, [key]: '' })).toThrow(key); + }); +}); diff --git a/test/forms.integration.spec.ts b/test/forms.integration.spec.ts index 943bc13..303efeb 100644 --- a/test/forms.integration.spec.ts +++ b/test/forms.integration.spec.ts @@ -1,5 +1,13 @@ import 'reflect-metadata'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; import request from 'supertest'; import { randomUUID } from 'node:crypto'; import { createApp } from '../src/bootstrap'; @@ -14,6 +22,9 @@ import { } from './fixtures'; import { syncForm, type ManagedForm } from '../integrations/payload/forms'; +const { postEvent } = vi.hoisted(() => ({ postEvent: vi.fn() })); +vi.mock('tc-bus-api-wrapper', () => ({ default: () => ({ postEvent }) })); + describe('forms API with real PostgreSQL', () => { let app: Awaited>; let db: DbService; @@ -23,7 +34,17 @@ describe('forms API with real PostgreSQL', () => { let machine: string; beforeAll(async () => { - app = await createApp(testConfig()); + app = await createApp({ + ...testConfig(), + busApi: { + BUSAPI_URL: 'https://bus.test/v6', + AUTH0_URL: 'https://auth.test', + AUTH0_AUDIENCE: 'https://api.test', + AUTH0_CLIENT_ID: 'test-client', + AUTH0_CLIENT_SECRET: 'test-secret', + KAFKA_ERROR_TOPIC: 'common.error.reporting', + }, + }); await app.listen(0, '127.0.0.1'); db = app.get(DbService); admin = await token({ roles: ['Administrator'] }); @@ -37,6 +58,9 @@ describe('forms API with real PostgreSQL', () => { scope: 'manage:forms read:forms-submissions', }); }); + beforeEach(() => { + postEvent.mockReset().mockResolvedValue(undefined); + }); afterAll(async () => { if (app) await app.close(); }); @@ -70,7 +94,9 @@ describe('forms API with real PostgreSQL', () => { await request(app.getHttpServer()).get('/v6/health').expect(200); await request(app.getHttpServer()).get('/v6/health/ready').expect(200); await request(app.getHttpServer()).get('/v6/docs-json').expect(200); - await request(app.getHttpServer()).get('/v6/forms/health/ready').expect(200); + await request(app.getHttpServer()) + .get('/v6/forms/health/ready') + .expect(200); const docs = await request(app.getHttpServer()) .get('/v6/forms/api-docs') .expect(200); @@ -179,6 +205,185 @@ describe('forms API with real PostgreSQL', () => { expect(replay.body.id).toBe(responses[0].body.id); }); + it.each([true, 'true'])( + 'publishes canonical committed data when kafka=%s', + async (kafka) => { + const key = await create(); + postEvent.mockImplementationOnce(async (event) => { + // A separate DB connection must see the full submission before publication. + expect( + await db.answer.count({ + where: { submissionId: event.payload.submissionId }, + }), + ).toBe(9); + }); + const receipt = await request(app.getHttpServer()) + .post(`/v6/forms/${key}/submissions`) + .auth(member, { type: 'bearer' }) + .set('Idempotency-Key', randomUUID()) + .send({ + version: 1, + answers: answers(), + sourcePage: '/events', + website: '', + kafka, + }) + .expect(201); + expect(postEvent).toHaveBeenCalledExactlyOnceWith({ + topic: 'form.submitted', + originator: 'forms-api-v6', + timestamp: receipt.body.submittedAt, + 'mime-type': 'application/json', + key: receipt.body.id, + payload: { + submissionId: receipt.body.id, + formKey: key, + version: 1, + submittedAt: receipt.body.submittedAt, + memberId: '12345', + sourcePage: '/events', + answers: { ...answers(), interests: ['design', 'dev'] }, + }, + }); + expect(Object.keys(receipt.body).sort()).toEqual([ + 'id', + 'submittedAt', + 'version', + ]); + expect( + ( + await db.submissionEvent.findUniqueOrThrow({ + where: { submissionId: receipt.body.id }, + }) + ).publishedAt, + ).not.toBeNull(); + }, + ); + + it.each([undefined, false, 'false'])( + 'does not publish when kafka=%s', + async (kafka) => { + const key = await create(); + const retryKey = randomUUID(); + const receipt = await request(app.getHttpServer()) + .post(`/v6/forms/${key}/submissions`) + .set('Idempotency-Key', retryKey) + .send({ version: 1, answers: answers(), kafka }) + .expect(201); + expect(postEvent).not.toHaveBeenCalled(); + expect( + await db.submissionEvent.count({ + where: { submissionId: receipt.body.id }, + }), + ).toBe(0); + await request(app.getHttpServer()) + .post(`/v6/forms/${key}/submissions`) + .set('Idempotency-Key', retryKey) + .send({ version: 1, answers: answers(), kafka: true }) + .expect(409); + expect(postEvent).not.toHaveBeenCalled(); + }, + ); + + it('serializes concurrent event retries and prevents changing the Kafka flag', async () => { + const key = await create(); + const retryKey = randomUUID(); + const body = { version: 1, answers: answers(), kafka: true }; + const receipts = await Promise.all( + Array.from({ length: 5 }, () => + request(app.getHttpServer()) + .post(`/v6/forms/${key}/submissions`) + .set('Idempotency-Key', retryKey) + .send(body) + .expect(201), + ), + ); + expect(new Set(receipts.map((receipt) => receipt.body.id)).size).toBe(1); + expect(postEvent).toHaveBeenCalledTimes(1); + expect(postEvent.mock.calls[0][0].payload.memberId).toBeNull(); + await request(app.getHttpServer()) + .post(`/v6/forms/${key}/submissions`) + .set('Idempotency-Key', retryKey) + .send({ ...body, kafka: false }) + .expect(409); + await request(app.getHttpServer()) + .post(`/v6/forms/${key}/submissions`) + .set('Idempotency-Key', retryKey) + .send({ ...body, answers: { ...answers(), age: 1 } }) + .expect(409); + expect(postEvent).toHaveBeenCalledTimes(1); + }); + + it('retries failed delivery against the saved submission even after retirement', async () => { + const key = await create(); + const retryKey = randomUUID(); + const body = { version: 1, answers: answers(), kafka: true }; + postEvent.mockRejectedValueOnce(new Error('sensitive provider error')); + const failure = await request(app.getHttpServer()) + .post(`/v6/forms/${key}/submissions`) + .set('Idempotency-Key', retryKey) + .send(body) + .expect(503); + expect(failure.text).not.toContain('sensitive provider error'); + const saved = await db.submission.findFirstOrThrow({ + where: { idempotencyKey: retryKey }, + include: { event: true }, + }); + expect(saved.event?.publishedAt).toBeNull(); + await request(app.getHttpServer()) + .post(`/v6/forms/${key}/versions/1/retire`) + .auth(admin, { type: 'bearer' }) + .expect(200); + for (let i = 0; i < 2; i++) { + const receipt = await request(app.getHttpServer()) + .post(`/v6/forms/${key}/submissions`) + .set('Idempotency-Key', retryKey) + .send(body) + .expect(201); + expect(receipt.body.id).toBe(saved.id); + } + expect(postEvent).toHaveBeenCalledTimes(2); + expect(postEvent.mock.calls[0][0]).toEqual(postEvent.mock.calls[1][0]); + expect( + await db.submission.count({ where: { idempotencyKey: retryKey } }), + ).toBe(1); + }); + + it('does not publish invalid, unauthorized, or stale submissions', async () => { + const key = await create(); + for (const kafka of ['yes', 'TRUE', 1, 0, {}, []]) { + await request(app.getHttpServer()) + .post(`/v6/forms/${key}/submissions`) + .set('Idempotency-Key', randomUUID()) + .send({ version: 1, answers: answers(), kafka }) + .expect(400); + } + await request(app.getHttpServer()) + .post(`/v6/forms/${key}/submissions`) + .set('Idempotency-Key', randomUUID()) + .send({ version: 1, answers: { ...answers(), age: 'bad' }, kafka: true }) + .expect(400); + await request(app.getHttpServer()) + .post(`/v6/forms/${key}/submissions`) + .set('Idempotency-Key', randomUUID()) + .auth(machine, { type: 'bearer' }) + .send({ version: 1, answers: answers(), kafka: true }) + .expect(403); + await request(app.getHttpServer()) + .post(`/v6/forms/${key}/versions/1/retire`) + .auth(admin, { type: 'bearer' }) + .expect(200); + await request(app.getHttpServer()) + .post(`/v6/forms/${key}/submissions`) + .set('Idempotency-Key', randomUUID()) + .send({ version: 1, answers: answers(), kafka: true }) + .expect(409); + expect(postEvent).not.toHaveBeenCalled(); + expect( + await db.submission.count({ where: { version: { form: { key } } } }), + ).toBe(0); + }); + it('rejects malformed, unknown, invalid, and missing fields atomically', async () => { const key = await create(); const invalid = [